install.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
  2. import { shouldInstallCompanion } from './install';
  3. import type { InstallConfig } from './types';
  4. const ORIGINAL_ENV = { ...process.env };
  5. const ORIGINAL_STDIN_IS_TTY = process.stdin.isTTY;
  6. const actualSkillSync = require('../hooks/auto-update-checker/skill-sync');
  7. const actualConfigManager = require('./config-manager');
  8. const actualBackgroundSubagents = require('./background-subagents');
  9. const actualPaths = require('./paths');
  10. const originalSyncBundledSkillsFromPackage =
  11. actualSkillSync.syncBundledSkillsFromPackage;
  12. const originalIsOpenCodeInstalled = actualConfigManager.isOpenCodeInstalled;
  13. const originalGetOpenCodeVersion = actualConfigManager.getOpenCodeVersion;
  14. const originalGetOpenCodePath = actualConfigManager.getOpenCodePath;
  15. const originalAddPluginToOpenCodeConfig =
  16. actualConfigManager.addPluginToOpenCodeConfig;
  17. const originalAddPluginToOpenCodeTuiConfig =
  18. actualConfigManager.addPluginToOpenCodeTuiConfig;
  19. const originalWarmOpenCodePluginCache =
  20. actualConfigManager.warmOpenCodePluginCache;
  21. const originalDisableDefaultAgents = actualConfigManager.disableDefaultAgents;
  22. const originalEnableLspByDefault = actualConfigManager.enableLspByDefault;
  23. const originalDetectCurrentConfig = actualConfigManager.detectCurrentConfig;
  24. const originalGenerateLiteConfig = actualConfigManager.generateLiteConfig;
  25. const originalWriteLiteConfig = actualConfigManager.writeLiteConfig;
  26. const originalIsBackgroundSubagentsEnabled =
  27. actualBackgroundSubagents.isBackgroundSubagentsEnabled;
  28. const originalDetectBackgroundSubagentsTarget =
  29. actualBackgroundSubagents.detectBackgroundSubagentsTarget;
  30. const originalExpandHomePath = actualBackgroundSubagents.expandHomePath;
  31. const originalGetBackgroundSubagentsBlock =
  32. actualBackgroundSubagents.getBackgroundSubagentsBlock;
  33. const originalWriteBackgroundSubagentsBlock =
  34. actualBackgroundSubagents.writeBackgroundSubagentsBlock;
  35. const originalManualBackgroundSubagentsInstructions =
  36. actualBackgroundSubagents.manualBackgroundSubagentsInstructions;
  37. const originalGetExistingLiteConfigPath = actualPaths.getExistingLiteConfigPath;
  38. let importCounter = 0;
  39. let mockSkippedResult: string[] = [];
  40. let mockFailedResult: string[] = [];
  41. let mockStagedResult: string[] = [];
  42. let mockAdoptedResult: string[] = [];
  43. let mockCustomizedResult: string[] = [];
  44. let receivedSkillSyncOptions: unknown;
  45. let enableInstallMocks = false;
  46. mock.module('../hooks/auto-update-checker/skill-sync', () => {
  47. return {
  48. ...actualSkillSync,
  49. syncBundledSkillsFromPackage: (packageRoot: string, options?: any) => {
  50. if (enableInstallMocks) {
  51. receivedSkillSyncOptions = options;
  52. return {
  53. installed: [],
  54. skippedExisting: mockSkippedResult,
  55. failed: mockFailedResult,
  56. staged: mockStagedResult,
  57. adopted: mockAdoptedResult,
  58. customized: mockCustomizedResult,
  59. };
  60. }
  61. return originalSyncBundledSkillsFromPackage(packageRoot, options);
  62. },
  63. };
  64. });
  65. mock.module('./config-manager', () => {
  66. return {
  67. ...actualConfigManager,
  68. isOpenCodeInstalled: async () =>
  69. enableInstallMocks ? true : originalIsOpenCodeInstalled(),
  70. getOpenCodeVersion: async () =>
  71. enableInstallMocks ? '1.0.0' : originalGetOpenCodeVersion(),
  72. getOpenCodePath: () =>
  73. enableInstallMocks
  74. ? '/usr/local/bin/opencode'
  75. : originalGetOpenCodePath(),
  76. addPluginToOpenCodeConfig: async () =>
  77. enableInstallMocks
  78. ? { success: true, configPath: '/path' }
  79. : originalAddPluginToOpenCodeConfig(),
  80. addPluginToOpenCodeTuiConfig: async () =>
  81. enableInstallMocks
  82. ? { success: true, configPath: '/path' }
  83. : originalAddPluginToOpenCodeTuiConfig(),
  84. warmOpenCodePluginCache: async () =>
  85. enableInstallMocks
  86. ? { success: true, configPath: '/path' }
  87. : originalWarmOpenCodePluginCache(),
  88. disableDefaultAgents: () =>
  89. enableInstallMocks
  90. ? { success: true, configPath: '/path' }
  91. : originalDisableDefaultAgents(),
  92. enableLspByDefault: () =>
  93. enableInstallMocks
  94. ? { success: true, configPath: '/path' }
  95. : originalEnableLspByDefault(),
  96. detectCurrentConfig: () =>
  97. enableInstallMocks
  98. ? { isInstalled: true }
  99. : originalDetectCurrentConfig(),
  100. generateLiteConfig: (cfg: any) =>
  101. enableInstallMocks ? {} : originalGenerateLiteConfig(cfg),
  102. writeLiteConfig: (cfg: any, path?: string) =>
  103. enableInstallMocks
  104. ? { success: true, configPath: '/path' }
  105. : originalWriteLiteConfig(cfg, path),
  106. };
  107. });
  108. mock.module('./background-subagents', () => {
  109. return {
  110. ...actualBackgroundSubagents,
  111. isBackgroundSubagentsEnabled: (env?: string) =>
  112. enableInstallMocks ? true : originalIsBackgroundSubagentsEnabled(env),
  113. detectBackgroundSubagentsTarget: () =>
  114. enableInstallMocks ? '/path' : originalDetectBackgroundSubagentsTarget(),
  115. expandHomePath: (p: string) =>
  116. enableInstallMocks ? p : originalExpandHomePath(p),
  117. getBackgroundSubagentsBlock: (target: string) =>
  118. enableInstallMocks ? '' : originalGetBackgroundSubagentsBlock(target),
  119. writeBackgroundSubagentsBlock: (target: string) =>
  120. enableInstallMocks ? {} : originalWriteBackgroundSubagentsBlock(target),
  121. manualBackgroundSubagentsInstructions: (opts?: any) =>
  122. enableInstallMocks
  123. ? ''
  124. : originalManualBackgroundSubagentsInstructions(opts),
  125. };
  126. });
  127. mock.module('./paths', () => {
  128. return {
  129. ...actualPaths,
  130. getExistingLiteConfigPath: () =>
  131. enableInstallMocks
  132. ? '/path/lite-config.json'
  133. : originalGetExistingLiteConfigPath(),
  134. };
  135. });
  136. function baseConfig(): InstallConfig {
  137. return {
  138. hasTmux: false,
  139. installCustomSkills: false,
  140. forceSkillSync: false,
  141. reset: false,
  142. backgroundSubagents: 'no',
  143. companion: 'ask',
  144. };
  145. }
  146. describe('shouldInstallCompanion', () => {
  147. afterEach(() => {
  148. process.env = { ...ORIGINAL_ENV };
  149. Object.defineProperty(process.stdin, 'isTTY', {
  150. configurable: true,
  151. value: ORIGINAL_STDIN_IS_TTY,
  152. });
  153. });
  154. test('dry-run defaults to skip on niri', async () => {
  155. process.env.NIRI_SOCKET = '/run/user/1000/niri.sock';
  156. const config = { ...baseConfig(), dryRun: true };
  157. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  158. expect(config.companion).toBe('no');
  159. });
  160. test('explicit companion yes still enables companion on niri', async () => {
  161. process.env.XDG_CURRENT_DESKTOP = 'niri';
  162. const config = { ...baseConfig(), companion: 'yes' as const };
  163. await expect(shouldInstallCompanion(config)).resolves.toBe(true);
  164. });
  165. test('dry-run defaults to skip outside niri', async () => {
  166. delete process.env.NIRI_SOCKET;
  167. delete process.env.XDG_CURRENT_DESKTOP;
  168. delete process.env.DESKTOP_SESSION;
  169. const config = { ...baseConfig(), dryRun: true };
  170. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  171. expect(config.companion).toBe('no');
  172. });
  173. });
  174. describe('install skill synchronization error mapping', () => {
  175. let logSpy: ReturnType<typeof mock>;
  176. let originalConsoleLog: typeof console.log;
  177. beforeEach(() => {
  178. enableInstallMocks = true;
  179. mockSkippedResult = [];
  180. mockFailedResult = [];
  181. mockStagedResult = [];
  182. mockAdoptedResult = [];
  183. mockCustomizedResult = [];
  184. receivedSkillSyncOptions = undefined;
  185. originalConsoleLog = console.log;
  186. logSpy = mock(() => {});
  187. console.log = logSpy;
  188. });
  189. afterEach(() => {
  190. enableInstallMocks = false;
  191. console.log = originalConsoleLog;
  192. });
  193. test('maps __lock__ to lock acquisition failure', async () => {
  194. mockFailedResult = ['__lock__'];
  195. const { install } = await import(`./install?test=${importCounter++}`);
  196. await install({
  197. skills: 'yes',
  198. tui: false,
  199. companion: 'no',
  200. });
  201. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  202. const hasLockErr = calls.some((msg: string) =>
  203. msg?.includes('Lock acquisition failed'),
  204. );
  205. expect(hasLockErr).toBe(true);
  206. const hasRawSentinel = calls.some((msg: string) =>
  207. msg?.includes('__lock__'),
  208. );
  209. expect(hasRawSentinel).toBe(false);
  210. // Verify summary does not count __lock__ as a failed skill
  211. const summaryMsg = calls.find((msg: string) =>
  212. msg?.includes('Skill synchronization complete'),
  213. );
  214. expect(summaryMsg).toBeDefined();
  215. expect(summaryMsg).toContain(
  216. '0 staged, 0 adopted, 0 customized, 0 failed.',
  217. );
  218. });
  219. test('maps __manifest__ to manifest write failure', async () => {
  220. mockFailedResult = ['__manifest__'];
  221. const { install } = await import(`./install?test=${importCounter++}`);
  222. await install({
  223. skills: 'yes',
  224. tui: false,
  225. companion: 'no',
  226. });
  227. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  228. const hasManifestErr = calls.some((msg: string) =>
  229. msg?.includes('Manifest write failed'),
  230. );
  231. expect(hasManifestErr).toBe(true);
  232. const hasRawSentinel = calls.some((msg: string) =>
  233. msg?.includes('__manifest__'),
  234. );
  235. expect(hasRawSentinel).toBe(false);
  236. // Verify summary does not count __manifest__ as a failed skill
  237. const summaryMsg = calls.find((msg: string) =>
  238. msg?.includes('Skill synchronization complete'),
  239. );
  240. expect(summaryMsg).toBeDefined();
  241. expect(summaryMsg).toContain(
  242. '0 staged, 0 adopted, 0 customized, 0 failed.',
  243. );
  244. });
  245. test('keeps normal skill names prefix as Failed: <name>', async () => {
  246. mockFailedResult = ['some-custom-skill'];
  247. const { install } = await import(`./install?test=${importCounter++}`);
  248. await install({
  249. skills: 'yes',
  250. tui: false,
  251. companion: 'no',
  252. });
  253. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  254. const hasSkillErr = calls.some((msg: string) =>
  255. msg?.includes('Failed: some-custom-skill'),
  256. );
  257. expect(hasSkillErr).toBe(true);
  258. // Verify summary DOES count standard skill failures in the failed count
  259. const summaryMsg = calls.find((msg: string) =>
  260. msg?.includes('Skill synchronization complete'),
  261. );
  262. expect(summaryMsg).toBeDefined();
  263. expect(summaryMsg).toContain(
  264. '0 staged, 0 adopted, 0 customized, 1 failed.',
  265. );
  266. });
  267. test('prints staged skills during sync', async () => {
  268. mockStagedResult = ['staged-skill'];
  269. const { install } = await import(`./install?test=${importCounter++}`);
  270. await install({
  271. skills: 'yes',
  272. tui: false,
  273. companion: 'no',
  274. });
  275. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  276. expect(
  277. calls.some((msg: string) =>
  278. msg?.includes('Staged for review: staged-skill'),
  279. ),
  280. ).toBe(true);
  281. });
  282. test('prints adopted skills during sync', async () => {
  283. mockAdoptedResult = ['adopted-skill'];
  284. const { install } = await import(`./install?test=${importCounter++}`);
  285. await install({
  286. skills: 'yes',
  287. tui: false,
  288. companion: 'no',
  289. });
  290. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  291. expect(
  292. calls.some((msg: string) => msg?.includes('Adopted: adopted-skill')),
  293. ).toBe(true);
  294. });
  295. test('prints customized skills during sync', async () => {
  296. mockCustomizedResult = ['customized-skill'];
  297. const { install } = await import(`./install?test=${importCounter++}`);
  298. await install({
  299. skills: 'yes',
  300. tui: false,
  301. companion: 'no',
  302. });
  303. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  304. expect(
  305. calls.some((msg: string) =>
  306. msg?.includes('Customized: customized-skill'),
  307. ),
  308. ).toBe(true);
  309. });
  310. test('does not double-print categorized skipped skills', async () => {
  311. mockSkippedResult = ['staged-skill', 'adopted-skill', 'customized-skill'];
  312. mockStagedResult = ['staged-skill'];
  313. mockAdoptedResult = ['adopted-skill'];
  314. mockCustomizedResult = ['customized-skill'];
  315. const { install } = await import(`./install?test=${importCounter++}`);
  316. await install({
  317. skills: 'yes',
  318. tui: false,
  319. companion: 'no',
  320. });
  321. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  322. expect(
  323. calls.some((msg: string) =>
  324. msg?.includes('Skipped/Preserved: staged-skill'),
  325. ),
  326. ).toBe(false);
  327. expect(
  328. calls.some((msg: string) =>
  329. msg?.includes('Skipped/Preserved: adopted-skill'),
  330. ),
  331. ).toBe(false);
  332. expect(
  333. calls.some((msg: string) =>
  334. msg?.includes('Skipped/Preserved: customized-skill'),
  335. ),
  336. ).toBe(false);
  337. const summaryMsg = calls.find((msg: string) =>
  338. msg?.includes('Skill synchronization complete'),
  339. );
  340. expect(summaryMsg).toBeDefined();
  341. expect(summaryMsg).toContain(
  342. '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
  343. );
  344. });
  345. test('passes force mode to bundled skill synchronization', async () => {
  346. const { install } = await import(`./install?test=${importCounter++}`);
  347. await install({
  348. skills: 'force',
  349. tui: false,
  350. companion: 'no',
  351. });
  352. expect(receivedSkillSyncOptions).toEqual({ force: true });
  353. });
  354. });