install.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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 mockFailedResult: string[] = [];
  40. let mockStagedResult: string[] = [];
  41. let mockAdoptedResult: string[] = [];
  42. let enableInstallMocks = false;
  43. mock.module('../hooks/auto-update-checker/skill-sync', () => {
  44. return {
  45. ...actualSkillSync,
  46. syncBundledSkillsFromPackage: (packageRoot: string, options?: any) =>
  47. enableInstallMocks
  48. ? {
  49. installed: [],
  50. skippedExisting: [],
  51. failed: mockFailedResult,
  52. staged: mockStagedResult,
  53. adopted: mockAdoptedResult,
  54. customized: [],
  55. }
  56. : originalSyncBundledSkillsFromPackage(packageRoot, options),
  57. };
  58. });
  59. mock.module('./config-manager', () => {
  60. return {
  61. ...actualConfigManager,
  62. isOpenCodeInstalled: async () =>
  63. enableInstallMocks ? true : originalIsOpenCodeInstalled(),
  64. getOpenCodeVersion: async () =>
  65. enableInstallMocks ? '1.0.0' : originalGetOpenCodeVersion(),
  66. getOpenCodePath: () =>
  67. enableInstallMocks
  68. ? '/usr/local/bin/opencode'
  69. : originalGetOpenCodePath(),
  70. addPluginToOpenCodeConfig: async () =>
  71. enableInstallMocks
  72. ? { success: true, configPath: '/path' }
  73. : originalAddPluginToOpenCodeConfig(),
  74. addPluginToOpenCodeTuiConfig: async () =>
  75. enableInstallMocks
  76. ? { success: true, configPath: '/path' }
  77. : originalAddPluginToOpenCodeTuiConfig(),
  78. warmOpenCodePluginCache: async () =>
  79. enableInstallMocks
  80. ? { success: true, configPath: '/path' }
  81. : originalWarmOpenCodePluginCache(),
  82. disableDefaultAgents: () =>
  83. enableInstallMocks
  84. ? { success: true, configPath: '/path' }
  85. : originalDisableDefaultAgents(),
  86. enableLspByDefault: () =>
  87. enableInstallMocks
  88. ? { success: true, configPath: '/path' }
  89. : originalEnableLspByDefault(),
  90. detectCurrentConfig: () =>
  91. enableInstallMocks
  92. ? { isInstalled: true }
  93. : originalDetectCurrentConfig(),
  94. generateLiteConfig: (cfg: any) =>
  95. enableInstallMocks ? {} : originalGenerateLiteConfig(cfg),
  96. writeLiteConfig: (cfg: any, path?: string) =>
  97. enableInstallMocks
  98. ? { success: true, configPath: '/path' }
  99. : originalWriteLiteConfig(cfg, path),
  100. };
  101. });
  102. mock.module('./background-subagents', () => {
  103. return {
  104. ...actualBackgroundSubagents,
  105. isBackgroundSubagentsEnabled: (env?: string) =>
  106. enableInstallMocks ? true : originalIsBackgroundSubagentsEnabled(env),
  107. detectBackgroundSubagentsTarget: () =>
  108. enableInstallMocks ? '/path' : originalDetectBackgroundSubagentsTarget(),
  109. expandHomePath: (p: string) =>
  110. enableInstallMocks ? p : originalExpandHomePath(p),
  111. getBackgroundSubagentsBlock: (target: string) =>
  112. enableInstallMocks ? '' : originalGetBackgroundSubagentsBlock(target),
  113. writeBackgroundSubagentsBlock: (target: string) =>
  114. enableInstallMocks ? {} : originalWriteBackgroundSubagentsBlock(target),
  115. manualBackgroundSubagentsInstructions: (opts?: any) =>
  116. enableInstallMocks
  117. ? ''
  118. : originalManualBackgroundSubagentsInstructions(opts),
  119. };
  120. });
  121. mock.module('./paths', () => {
  122. return {
  123. ...actualPaths,
  124. getExistingLiteConfigPath: () =>
  125. enableInstallMocks
  126. ? '/path/lite-config.json'
  127. : originalGetExistingLiteConfigPath(),
  128. };
  129. });
  130. function baseConfig(): InstallConfig {
  131. return {
  132. hasTmux: false,
  133. installCustomSkills: false,
  134. reset: false,
  135. backgroundSubagents: 'no',
  136. companion: 'ask',
  137. };
  138. }
  139. describe('shouldInstallCompanion', () => {
  140. afterEach(() => {
  141. process.env = { ...ORIGINAL_ENV };
  142. Object.defineProperty(process.stdin, 'isTTY', {
  143. configurable: true,
  144. value: ORIGINAL_STDIN_IS_TTY,
  145. });
  146. });
  147. test('dry-run defaults to skip on niri', async () => {
  148. process.env.NIRI_SOCKET = '/run/user/1000/niri.sock';
  149. const config = { ...baseConfig(), dryRun: true };
  150. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  151. expect(config.companion).toBe('no');
  152. });
  153. test('explicit companion yes still enables companion on niri', async () => {
  154. process.env.XDG_CURRENT_DESKTOP = 'niri';
  155. const config = { ...baseConfig(), companion: 'yes' as const };
  156. await expect(shouldInstallCompanion(config)).resolves.toBe(true);
  157. });
  158. test('dry-run defaults to skip outside niri', async () => {
  159. delete process.env.NIRI_SOCKET;
  160. delete process.env.XDG_CURRENT_DESKTOP;
  161. delete process.env.DESKTOP_SESSION;
  162. const config = { ...baseConfig(), dryRun: true };
  163. await expect(shouldInstallCompanion(config)).resolves.toBe(false);
  164. expect(config.companion).toBe('no');
  165. });
  166. });
  167. describe('install skill synchronization error mapping', () => {
  168. let logSpy: ReturnType<typeof mock>;
  169. let originalConsoleLog: typeof console.log;
  170. beforeEach(() => {
  171. enableInstallMocks = true;
  172. mockFailedResult = [];
  173. mockStagedResult = [];
  174. mockAdoptedResult = [];
  175. originalConsoleLog = console.log;
  176. logSpy = mock(() => {});
  177. console.log = logSpy;
  178. });
  179. afterEach(() => {
  180. enableInstallMocks = false;
  181. console.log = originalConsoleLog;
  182. });
  183. test('maps __lock__ to lock acquisition failure', async () => {
  184. mockFailedResult = ['__lock__'];
  185. const { install } = await import(`./install?test=${importCounter++}`);
  186. await install({
  187. skills: 'yes',
  188. tui: false,
  189. companion: 'no',
  190. });
  191. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  192. const hasLockErr = calls.some((msg: string) =>
  193. msg?.includes('Lock acquisition failed'),
  194. );
  195. expect(hasLockErr).toBe(true);
  196. const hasRawSentinel = calls.some((msg: string) =>
  197. msg?.includes('__lock__'),
  198. );
  199. expect(hasRawSentinel).toBe(false);
  200. // Verify summary does not count __lock__ as a failed skill
  201. const summaryMsg = calls.find((msg: string) =>
  202. msg?.includes('Skill synchronization complete'),
  203. );
  204. expect(summaryMsg).toBeDefined();
  205. expect(summaryMsg).not.toContain('Processed');
  206. expect(summaryMsg).toContain('0 failed.');
  207. });
  208. test('maps __manifest__ to manifest write failure', async () => {
  209. mockFailedResult = ['__manifest__'];
  210. const { install } = await import(`./install?test=${importCounter++}`);
  211. await install({
  212. skills: 'yes',
  213. tui: false,
  214. companion: 'no',
  215. });
  216. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  217. const hasManifestErr = calls.some((msg: string) =>
  218. msg?.includes('Manifest write failed'),
  219. );
  220. expect(hasManifestErr).toBe(true);
  221. const hasRawSentinel = calls.some((msg: string) =>
  222. msg?.includes('__manifest__'),
  223. );
  224. expect(hasRawSentinel).toBe(false);
  225. // Verify summary does not count __manifest__ as a failed skill
  226. const summaryMsg = calls.find((msg: string) =>
  227. msg?.includes('Skill synchronization complete'),
  228. );
  229. expect(summaryMsg).toBeDefined();
  230. expect(summaryMsg).not.toContain('Processed');
  231. expect(summaryMsg).toContain('0 failed.');
  232. });
  233. test('keeps normal skill names prefix as Failed: <name>', async () => {
  234. mockFailedResult = ['some-custom-skill'];
  235. const { install } = await import(`./install?test=${importCounter++}`);
  236. await install({
  237. skills: 'yes',
  238. tui: false,
  239. companion: 'no',
  240. });
  241. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  242. const hasSkillErr = calls.some((msg: string) =>
  243. msg?.includes('Failed: some-custom-skill'),
  244. );
  245. expect(hasSkillErr).toBe(true);
  246. // Verify summary DOES count standard skill failures in the failed count
  247. const summaryMsg = calls.find((msg: string) =>
  248. msg?.includes('Skill synchronization complete'),
  249. );
  250. expect(summaryMsg).toBeDefined();
  251. expect(summaryMsg).not.toContain('Processed');
  252. expect(summaryMsg).toContain('1 failed.');
  253. });
  254. test('prints staged skills during sync', async () => {
  255. mockStagedResult = ['staged-skill'];
  256. const { install } = await import(`./install?test=${importCounter++}`);
  257. await install({
  258. skills: 'yes',
  259. tui: false,
  260. companion: 'no',
  261. });
  262. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  263. expect(
  264. calls.some((msg: string) =>
  265. msg?.includes('Staged for review: staged-skill'),
  266. ),
  267. ).toBe(true);
  268. });
  269. test('prints adopted skills during sync', async () => {
  270. mockAdoptedResult = ['adopted-skill'];
  271. const { install } = await import(`./install?test=${importCounter++}`);
  272. await install({
  273. skills: 'yes',
  274. tui: false,
  275. companion: 'no',
  276. });
  277. const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
  278. expect(
  279. calls.some((msg: string) => msg?.includes('Adopted: adopted-skill')),
  280. ).toBe(true);
  281. });
  282. });