install.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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. installCustomSkills: false,
  139. forceSkillSync: false,
  140. reset: false,
  141. backgroundSubagents: 'no',
  142. companion: 'ask',
  143. };
  144. }
  145. describe('shouldInstallCompanion', () => {
  146. afterEach(() => {
  147. process.env = { ...ORIGINAL_ENV };
  148. Object.defineProperty(process.stdin, 'isTTY', {
  149. configurable: true,
  150. value: ORIGINAL_STDIN_IS_TTY,
  151. });
  152. });
  153. test('dry-run defaults to skip on niri', async () => {
  154. process.env.NIRI_SOCKET = '/run/user/1000/niri.sock';
  155. const config = { ...baseConfig(), dryRun: true };
  156. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  157. expect(config.companion).toBe('no');
  158. });
  159. test('explicit companion yes still enables companion on niri', async () => {
  160. process.env.XDG_CURRENT_DESKTOP = 'niri';
  161. const config = { ...baseConfig(), companion: 'yes' as const };
  162. await expect(shouldInstallCompanion(config)).resolves.toBe(true);
  163. });
  164. test('dry-run defaults to skip outside niri', async () => {
  165. delete process.env.NIRI_SOCKET;
  166. delete process.env.XDG_CURRENT_DESKTOP;
  167. delete process.env.DESKTOP_SESSION;
  168. const config = { ...baseConfig(), dryRun: true };
  169. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  170. expect(config.companion).toBe('no');
  171. });
  172. });
  173. describe('install skill synchronization error mapping', () => {
  174. let logSpy: ReturnType<typeof mock>;
  175. let originalConsoleLog: typeof console.log;
  176. beforeEach(() => {
  177. enableInstallMocks = true;
  178. mockSkippedResult = [];
  179. mockFailedResult = [];
  180. mockStagedResult = [];
  181. mockAdoptedResult = [];
  182. mockCustomizedResult = [];
  183. receivedSkillSyncOptions = undefined;
  184. originalConsoleLog = console.log;
  185. logSpy = mock(() => {});
  186. console.log = logSpy;
  187. });
  188. afterEach(() => {
  189. enableInstallMocks = false;
  190. console.log = originalConsoleLog;
  191. });
  192. test('maps __lock__ to lock acquisition failure', async () => {
  193. mockFailedResult = ['__lock__'];
  194. const { install } = await import(`./install?test=${importCounter++}`);
  195. await install({
  196. skills: 'yes',
  197. tui: false,
  198. companion: 'no',
  199. });
  200. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  201. const hasLockErr = calls.some((msg: string) =>
  202. msg?.includes('Lock acquisition failed'),
  203. );
  204. expect(hasLockErr).toBe(true);
  205. const hasRawSentinel = calls.some((msg: string) =>
  206. msg?.includes('__lock__'),
  207. );
  208. expect(hasRawSentinel).toBe(false);
  209. // Verify summary does not count __lock__ as a failed skill
  210. const summaryMsg = calls.find((msg: string) =>
  211. msg?.includes('Skill synchronization complete'),
  212. );
  213. expect(summaryMsg).toBeDefined();
  214. expect(summaryMsg).toContain(
  215. '0 staged, 0 adopted, 0 customized, 0 failed.',
  216. );
  217. });
  218. test('maps __manifest__ to manifest write failure', async () => {
  219. mockFailedResult = ['__manifest__'];
  220. const { install } = await import(`./install?test=${importCounter++}`);
  221. await install({
  222. skills: 'yes',
  223. tui: false,
  224. companion: 'no',
  225. });
  226. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  227. const hasManifestErr = calls.some((msg: string) =>
  228. msg?.includes('Manifest write failed'),
  229. );
  230. expect(hasManifestErr).toBe(true);
  231. const hasRawSentinel = calls.some((msg: string) =>
  232. msg?.includes('__manifest__'),
  233. );
  234. expect(hasRawSentinel).toBe(false);
  235. // Verify summary does not count __manifest__ as a failed skill
  236. const summaryMsg = calls.find((msg: string) =>
  237. msg?.includes('Skill synchronization complete'),
  238. );
  239. expect(summaryMsg).toBeDefined();
  240. expect(summaryMsg).toContain(
  241. '0 staged, 0 adopted, 0 customized, 0 failed.',
  242. );
  243. });
  244. test('keeps normal skill names prefix as Failed: <name>', async () => {
  245. mockFailedResult = ['some-custom-skill'];
  246. const { install } = await import(`./install?test=${importCounter++}`);
  247. await install({
  248. skills: 'yes',
  249. tui: false,
  250. companion: 'no',
  251. });
  252. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  253. const hasSkillErr = calls.some((msg: string) =>
  254. msg?.includes('Failed: some-custom-skill'),
  255. );
  256. expect(hasSkillErr).toBe(true);
  257. // Verify summary DOES count standard skill failures in the failed count
  258. const summaryMsg = calls.find((msg: string) =>
  259. msg?.includes('Skill synchronization complete'),
  260. );
  261. expect(summaryMsg).toBeDefined();
  262. expect(summaryMsg).toContain(
  263. '0 staged, 0 adopted, 0 customized, 1 failed.',
  264. );
  265. });
  266. test('prints staged skills during sync', async () => {
  267. mockStagedResult = ['staged-skill'];
  268. const { install } = await import(`./install?test=${importCounter++}`);
  269. await install({
  270. skills: 'yes',
  271. tui: false,
  272. companion: 'no',
  273. });
  274. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  275. expect(
  276. calls.some((msg: string) =>
  277. msg?.includes('Staged for review: staged-skill'),
  278. ),
  279. ).toBe(true);
  280. });
  281. test('prints adopted skills during sync', async () => {
  282. mockAdoptedResult = ['adopted-skill'];
  283. const { install } = await import(`./install?test=${importCounter++}`);
  284. await install({
  285. skills: 'yes',
  286. tui: false,
  287. companion: 'no',
  288. });
  289. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  290. expect(
  291. calls.some((msg: string) => msg?.includes('Adopted: adopted-skill')),
  292. ).toBe(true);
  293. });
  294. test('prints customized skills during sync', async () => {
  295. mockCustomizedResult = ['customized-skill'];
  296. const { install } = await import(`./install?test=${importCounter++}`);
  297. await install({
  298. skills: 'yes',
  299. tui: false,
  300. companion: 'no',
  301. });
  302. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  303. expect(
  304. calls.some((msg: string) =>
  305. msg?.includes('Customized: customized-skill'),
  306. ),
  307. ).toBe(true);
  308. });
  309. test('does not double-print categorized skipped skills', async () => {
  310. mockSkippedResult = ['staged-skill', 'adopted-skill', 'customized-skill'];
  311. mockStagedResult = ['staged-skill'];
  312. mockAdoptedResult = ['adopted-skill'];
  313. mockCustomizedResult = ['customized-skill'];
  314. const { install } = await import(`./install?test=${importCounter++}`);
  315. await install({
  316. skills: 'yes',
  317. tui: false,
  318. companion: 'no',
  319. });
  320. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  321. expect(
  322. calls.some((msg: string) =>
  323. msg?.includes('Skipped/Preserved: staged-skill'),
  324. ),
  325. ).toBe(false);
  326. expect(
  327. calls.some((msg: string) =>
  328. msg?.includes('Skipped/Preserved: adopted-skill'),
  329. ),
  330. ).toBe(false);
  331. expect(
  332. calls.some((msg: string) =>
  333. msg?.includes('Skipped/Preserved: customized-skill'),
  334. ),
  335. ).toBe(false);
  336. const summaryMsg = calls.find((msg: string) =>
  337. msg?.includes('Skill synchronization complete'),
  338. );
  339. expect(summaryMsg).toBeDefined();
  340. expect(summaryMsg).toContain(
  341. '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
  342. );
  343. });
  344. test('passes force mode to bundled skill synchronization', async () => {
  345. const { install } = await import(`./install?test=${importCounter++}`);
  346. await install({
  347. skills: 'force',
  348. tui: false,
  349. companion: 'no',
  350. });
  351. expect(receivedSkillSyncOptions).toEqual({ force: true });
  352. });
  353. });