upgrade.test.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. import {
  6. detectInstallMethod,
  7. deriveInstallDir,
  8. parseSemver,
  9. compareVersions,
  10. isUpdateAvailable,
  11. normalizeVersion,
  12. stripV,
  13. parseLatestTagFromLocation,
  14. reindexAdvisory,
  15. runUpgrade,
  16. verifyResolvedVersion,
  17. buildWindowsUpgradeScript,
  18. NPM_PACKAGE,
  19. type InstallMethod,
  20. type UpgradeDeps,
  21. } from '../src/upgrade';
  22. import { EXTRACTION_VERSION } from '../src/extraction/extraction-version';
  23. import { CodeGraph } from '../src';
  24. // ---------------------------------------------------------------------------
  25. // detectInstallMethod — structural detection from the running file's path
  26. // ---------------------------------------------------------------------------
  27. describe('detectInstallMethod', () => {
  28. // A bundle exists if a vendored node + launcher sit next to lib/.
  29. function bundleExists(present: Set<string>) {
  30. return (p: string) => present.has(p.replace(/\\/g, '/'));
  31. }
  32. it('detects a unix bundle and derives the install dir from the versions/ layout', () => {
  33. const root = '/home/u/.codegraph/versions/v0.9.9';
  34. const filename = `${root}/lib/dist/bin/codegraph.js`;
  35. const present = new Set([`${root}/node`, `${root}/bin/codegraph`, '/home/u/.codegraph']);
  36. const m = detectInstallMethod({
  37. filename,
  38. platform: 'linux',
  39. cwd: '/home/u/project',
  40. exists: bundleExists(present),
  41. });
  42. expect(m).toEqual({
  43. kind: 'bundle',
  44. os: 'unix',
  45. bundleRoot: root,
  46. installDir: '/home/u/.codegraph',
  47. });
  48. });
  49. it('detects a windows bundle and derives the install dir from current\\', () => {
  50. const root = 'C:/Users/u/AppData/Local/codegraph/current';
  51. const filename = `${root}/lib/dist/bin/codegraph.js`;
  52. const present = new Set([`${root}/node.exe`, `${root}/bin/codegraph.cmd`]);
  53. const m = detectInstallMethod({
  54. filename,
  55. platform: 'win32',
  56. cwd: 'C:/Users/u/project',
  57. exists: bundleExists(present),
  58. }) as Extract<InstallMethod, { kind: 'bundle' }>;
  59. expect(m.kind).toBe('bundle');
  60. expect(m.os).toBe('windows');
  61. // win32 path math emits backslashes; compare separator-independently.
  62. expect(m.installDir?.replace(/\\/g, '/')).toBe('C:/Users/u/AppData/Local/codegraph');
  63. });
  64. it('detects a global npm install', () => {
  65. const filename = '/usr/local/lib/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js';
  66. const m = detectInstallMethod({
  67. filename,
  68. platform: 'linux',
  69. cwd: '/home/u/project',
  70. exists: () => false,
  71. });
  72. expect(m).toEqual({ kind: 'npm', scope: 'global' });
  73. });
  74. it('detects a local (project) npm install as local', () => {
  75. const cwd = '/home/u/project';
  76. const filename = `${cwd}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`;
  77. const m = detectInstallMethod({ filename, platform: 'linux', cwd, exists: () => false });
  78. expect(m).toEqual({ kind: 'npm', scope: 'local' });
  79. });
  80. it('detects an npx run from the _npx cache', () => {
  81. const filename = '/home/u/.npm/_npx/abc123/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js';
  82. const m = detectInstallMethod({ filename, platform: 'linux', cwd: '/home/u', exists: () => false });
  83. expect(m).toEqual({ kind: 'npx' });
  84. });
  85. // The npm thin-installer's per-platform package IS a complete bundle
  86. // (vendored node + bin/ launcher) sitting inside node_modules. The layout
  87. // sniff must not win over the node_modules path check, or `upgrade` curls
  88. // install.sh into ~/.codegraph — a second install that loses the PATH race
  89. // to npm's shim, so `codegraph -v` stays on the old version forever.
  90. it('detects the npm thin-installer platform package as npm, not bundle', () => {
  91. const root = '/usr/local/lib/node_modules/@colbymchenry/codegraph/node_modules/@colbymchenry/codegraph-linux-x64';
  92. const filename = `${root}/lib/dist/bin/codegraph.js`;
  93. const present = new Set([`${root}/node`, `${root}/bin/codegraph`]);
  94. const m = detectInstallMethod({
  95. filename,
  96. platform: 'linux',
  97. cwd: '/home/u/project',
  98. exists: bundleExists(present),
  99. });
  100. expect(m).toEqual({ kind: 'npm', scope: 'global' });
  101. });
  102. it('detects a project-local thin-installer platform package as npm local', () => {
  103. const cwd = '/home/u/project';
  104. const root = `${cwd}/node_modules/@colbymchenry/codegraph/node_modules/@colbymchenry/codegraph-darwin-arm64`;
  105. const filename = `${root}/lib/dist/bin/codegraph.js`;
  106. const present = new Set([`${root}/node`, `${root}/bin/codegraph`]);
  107. const m = detectInstallMethod({ filename, platform: 'darwin', cwd, exists: bundleExists(present) });
  108. expect(m).toEqual({ kind: 'npm', scope: 'local' });
  109. });
  110. it('still detects an npx run when the cached platform package has the bundle layout', () => {
  111. const root = '/home/u/.npm/_npx/abc123/node_modules/@colbymchenry/codegraph/node_modules/@colbymchenry/codegraph-linux-x64';
  112. const filename = `${root}/lib/dist/bin/codegraph.js`;
  113. const present = new Set([`${root}/node`, `${root}/bin/codegraph`]);
  114. const m = detectInstallMethod({ filename, platform: 'linux', cwd: '/home/u', exists: bundleExists(present) });
  115. expect(m).toEqual({ kind: 'npx' });
  116. });
  117. it('detects a source checkout via sibling package.json + .git', () => {
  118. const repo = '/home/u/dev/codegraph';
  119. const filename = `${repo}/dist/bin/codegraph.js`;
  120. const present = new Set([`${repo}/package.json`, `${repo}/.git`]);
  121. const m = detectInstallMethod({
  122. filename,
  123. platform: 'darwin',
  124. cwd: repo,
  125. exists: bundleExists(present),
  126. });
  127. expect(m).toEqual({ kind: 'source', root: repo });
  128. });
  129. it('returns unknown for an unrecognized layout', () => {
  130. const m = detectInstallMethod({
  131. filename: '/opt/weird/place/codegraph.js',
  132. platform: 'linux',
  133. cwd: '/tmp',
  134. exists: () => false,
  135. });
  136. expect(m.kind).toBe('unknown');
  137. });
  138. });
  139. describe('deriveInstallDir', () => {
  140. it('unix: returns the dir above versions/', () => {
  141. expect(deriveInstallDir('/a/b/.codegraph/versions/v1.2.3', 'unix', () => true)).toBe('/a/b/.codegraph');
  142. });
  143. it('unix: null when not under versions/', () => {
  144. expect(deriveInstallDir('/a/b/somewhere', 'unix', () => true)).toBeNull();
  145. });
  146. it('windows: returns the parent of current\\', () => {
  147. expect(deriveInstallDir('C:/x/codegraph/current', 'windows', () => true)?.replace(/\\/g, '/')).toBe('C:/x/codegraph');
  148. });
  149. it('windows: null when basename is not current', () => {
  150. expect(deriveInstallDir('C:/x/codegraph/v1', 'windows', () => true)).toBeNull();
  151. });
  152. });
  153. // ---------------------------------------------------------------------------
  154. // version helpers
  155. // ---------------------------------------------------------------------------
  156. describe('version helpers', () => {
  157. it('parseSemver handles v-prefix and prerelease', () => {
  158. expect(parseSemver('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, pre: null });
  159. expect(parseSemver('1.2.3-rc.1')).toEqual({ major: 1, minor: 2, patch: 3, pre: 'rc.1' });
  160. expect(parseSemver('not-a-version')).toBeNull();
  161. });
  162. it('compareVersions orders correctly incl. prerelease < release', () => {
  163. expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
  164. expect(compareVersions('1.0.0', '1.1.0')).toBeLessThan(0);
  165. expect(compareVersions('v2.0.0', '2.0.0')).toBe(0);
  166. expect(compareVersions('1.0.0-rc.1', '1.0.0')).toBeLessThan(0);
  167. });
  168. it('isUpdateAvailable compares, and falls back to string-inequality for unparseable', () => {
  169. expect(isUpdateAvailable('0.9.8', '0.9.9')).toBe(true);
  170. expect(isUpdateAvailable('0.9.9', '0.9.9')).toBe(false);
  171. expect(isUpdateAvailable('0.9.9', '0.9.8')).toBe(false);
  172. // dev sentinel can't parse → any difference means "update available"
  173. expect(isUpdateAvailable('0.0.0-unknown', '0.9.9')).toBe(true);
  174. });
  175. it('normalizeVersion / stripV round-trip', () => {
  176. expect(normalizeVersion('0.9.9')).toBe('v0.9.9');
  177. expect(normalizeVersion('v0.9.9')).toBe('v0.9.9');
  178. expect(stripV('v0.9.9')).toBe('0.9.9');
  179. expect(stripV('0.9.9')).toBe('0.9.9');
  180. });
  181. it('parseLatestTagFromLocation extracts the tag from a releases redirect', () => {
  182. expect(parseLatestTagFromLocation('https://github.com/colbymchenry/codegraph/releases/tag/v0.9.9')).toBe('v0.9.9');
  183. expect(parseLatestTagFromLocation('https://github.com/o/r/releases/tag/v1.2.3?foo=bar')).toBe('v1.2.3');
  184. expect(parseLatestTagFromLocation(undefined)).toBeNull();
  185. expect(parseLatestTagFromLocation('https://github.com/o/r/releases')).toBeNull();
  186. });
  187. it('reindexAdvisory mentions the refresh commands', () => {
  188. const a = reindexAdvisory();
  189. expect(a).toContain('codegraph sync');
  190. expect(a).toContain('codegraph index -f');
  191. });
  192. it('buildWindowsUpgradeScript targets the right asset per arch and renames-not-deletes the exe', () => {
  193. const arm = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'arm64');
  194. expect(arm).toContain('releases/download/v1.2.3/codegraph-win32-arm64.zip');
  195. expect(arm).toContain("$dest='C:\\cg\\current'");
  196. expect(arm).toContain('Rename-Item'); // never Remove-Item on the locked exe
  197. expect(arm).not.toMatch(/Remove-Item[^;]*\$dest'?\s*;/); // doesn't delete current\
  198. const x64 = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'x64');
  199. expect(x64).toContain('codegraph-win32-x64.zip');
  200. });
  201. });
  202. // ---------------------------------------------------------------------------
  203. // runUpgrade orchestration — mocked side-effects
  204. // ---------------------------------------------------------------------------
  205. interface Calls {
  206. runs: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }>;
  207. captures: Array<{ cmd: string; args: string[] }>;
  208. logs: string[];
  209. errors: string[];
  210. }
  211. function makeDeps(
  212. overrides: Partial<UpgradeDeps> & { method: InstallMethod; currentVersion: string },
  213. runExit = 0
  214. ): { deps: UpgradeDeps; calls: Calls } {
  215. const calls: Calls = { runs: [], captures: [], logs: [], errors: [] };
  216. const deps: UpgradeDeps = {
  217. currentVersion: overrides.currentVersion,
  218. method: overrides.method,
  219. resolveLatest: overrides.resolveLatest ?? (async () => 'v0.9.9'),
  220. run: (cmd, args, env) => {
  221. calls.runs.push({ cmd, args, env });
  222. return runExit;
  223. },
  224. // Default probe: spawn fails → 'inconclusive'. Tests that exercise the
  225. // post-upgrade version check override this.
  226. capture: (cmd, args) => {
  227. calls.captures.push({ cmd, args });
  228. return overrides.capture ? overrides.capture(cmd, args) : null;
  229. },
  230. hasCommand: overrides.hasCommand ?? ((c) => c === 'curl'),
  231. log: (m) => calls.logs.push(m),
  232. warn: (m) => calls.logs.push(m),
  233. error: (m) => calls.errors.push(m),
  234. platform: overrides.platform ?? 'linux',
  235. };
  236. return { deps, calls };
  237. }
  238. /** Decode a `-EncodedCommand` base64 (UTF-16LE) payload back to its script. */
  239. function decodeEncodedCommand(args: string[]): string {
  240. const i = args.indexOf('-EncodedCommand');
  241. if (i < 0) throw new Error('no -EncodedCommand in args');
  242. return Buffer.from(args[i + 1]!, 'base64').toString('utf16le');
  243. }
  244. describe('runUpgrade', () => {
  245. it('does nothing when already up to date', async () => {
  246. const { deps, calls } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.9' });
  247. const code = await runUpgrade({}, deps);
  248. expect(code).toBe(0);
  249. expect(calls.runs).toHaveLength(0);
  250. expect(calls.logs.join('\n')).toMatch(/up to date/i);
  251. });
  252. it('--check reports an available update without running anything', async () => {
  253. const { deps, calls } = makeDeps({
  254. method: { kind: 'npm', scope: 'global' },
  255. currentVersion: '0.9.8',
  256. });
  257. const code = await runUpgrade({ check: true }, deps);
  258. expect(code).toBe(0);
  259. expect(calls.runs).toHaveLength(0);
  260. expect(calls.logs.join('\n')).toMatch(/update is available/i);
  261. });
  262. it('unix bundle: runs the installer via sh with the derived install dir', async () => {
  263. const { deps, calls } = makeDeps({
  264. method: { kind: 'bundle', os: 'unix', bundleRoot: '/h/.codegraph/versions/v0.9.8', installDir: '/h/.codegraph' },
  265. currentVersion: '0.9.8',
  266. });
  267. const code = await runUpgrade({}, deps);
  268. expect(code).toBe(0);
  269. expect(calls.runs).toHaveLength(1);
  270. expect(calls.runs[0].cmd).toBe('sh');
  271. expect(calls.runs[0].args[0]).toBe('-c');
  272. expect(calls.runs[0].args[1]).toContain('curl -fsSL');
  273. expect(calls.runs[0].args[1]).toContain('| sh');
  274. expect(calls.runs[0].env?.CODEGRAPH_INSTALL_DIR).toBe('/h/.codegraph');
  275. expect(calls.logs.join('\n')).toMatch(/codegraph sync/); // re-index advisory printed
  276. });
  277. it('unix bundle: falls back to wget, and errors when neither downloader exists', async () => {
  278. const { deps, calls } = makeDeps({
  279. method: { kind: 'bundle', os: 'unix', bundleRoot: '/h/.codegraph/versions/v0.9.8', installDir: null },
  280. currentVersion: '0.9.8',
  281. hasCommand: () => false,
  282. });
  283. const code = await runUpgrade({}, deps);
  284. expect(code).toBe(1);
  285. expect(calls.runs).toHaveLength(0);
  286. expect(calls.errors.join('\n')).toMatch(/curl nor wget/i);
  287. });
  288. it('windows bundle: runs a synchronous in-place (rename + extract) powershell upgrade', async () => {
  289. const { deps, calls } = makeDeps({
  290. method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' },
  291. currentVersion: '0.9.8',
  292. platform: 'win32',
  293. });
  294. const code = await runUpgrade({}, deps);
  295. expect(code).toBe(0);
  296. expect(calls.runs).toHaveLength(1);
  297. expect(calls.runs[0].cmd).toBe('powershell.exe');
  298. const decoded = decodeEncodedCommand(calls.runs[0].args);
  299. // Downloads the right asset, renames the locked exe aside, copies over current\.
  300. expect(decoded).toContain('releases/download/v0.9.9/codegraph-win32-');
  301. expect(decoded).toContain('Rename-Item');
  302. expect(decoded).toContain('node.exe.old-');
  303. expect(decoded).toContain('Copy-Item');
  304. });
  305. it('windows bundle: a non-zero installer exit is a failure', async () => {
  306. const { deps, calls } = makeDeps(
  307. {
  308. method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' },
  309. currentVersion: '0.9.8',
  310. platform: 'win32',
  311. },
  312. 1
  313. );
  314. const code = await runUpgrade({}, deps);
  315. expect(code).toBe(1);
  316. expect(calls.errors.join('\n')).toMatch(/exited with code/i);
  317. });
  318. it('npm global: shells out to npm install -g @pkg@latest', async () => {
  319. const { deps, calls } = makeDeps({
  320. method: { kind: 'npm', scope: 'global' },
  321. currentVersion: '0.9.8',
  322. });
  323. const code = await runUpgrade({}, deps);
  324. expect(code).toBe(0);
  325. expect(calls.runs[0].cmd).toBe('npm');
  326. expect(calls.runs[0].args).toEqual(['install', '-g', `${NPM_PACKAGE}@latest`]);
  327. });
  328. it('npm on win32 routes through cmd.exe (a direct npm.cmd spawn EINVALs on modern Node)', async () => {
  329. const { deps, calls } = makeDeps({
  330. method: { kind: 'npm', scope: 'global' },
  331. currentVersion: '0.9.8',
  332. platform: 'win32',
  333. });
  334. await runUpgrade({}, deps);
  335. expect(calls.runs[0].cmd).toBe('cmd.exe');
  336. expect(calls.runs[0].args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
  337. expect(calls.runs[0].args[3]).toBe(`npm install -g ${NPM_PACKAGE}@latest`);
  338. });
  339. it('npm: a pinned version is passed through as @<version>', async () => {
  340. const { deps, calls } = makeDeps({
  341. method: { kind: 'npm', scope: 'global' },
  342. currentVersion: '0.9.9',
  343. });
  344. await runUpgrade({ version: '0.9.8' }, deps);
  345. // npm spec carries no leading "v".
  346. expect(calls.runs[0].args).toEqual(['install', '-g', `${NPM_PACKAGE}@0.9.8`]);
  347. });
  348. it('npm: surfaces a non-zero exit as failure', async () => {
  349. const { deps, calls } = makeDeps(
  350. { method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' },
  351. 1
  352. );
  353. const code = await runUpgrade({}, deps);
  354. expect(code).toBe(1);
  355. expect(calls.errors.join('\n')).toMatch(/npm exited/i);
  356. });
  357. it('npx: nothing to upgrade', async () => {
  358. const { deps, calls } = makeDeps({ method: { kind: 'npx' }, currentVersion: '0.9.8' });
  359. const code = await runUpgrade({}, deps);
  360. expect(code).toBe(0);
  361. expect(calls.runs).toHaveLength(0);
  362. expect(calls.logs.join('\n')).toMatch(/nothing to upgrade/i);
  363. });
  364. it('source: tells the user to git pull, runs nothing', async () => {
  365. const { deps, calls } = makeDeps({
  366. method: { kind: 'source', root: '/dev/codegraph' },
  367. currentVersion: '0.9.8',
  368. });
  369. const code = await runUpgrade({}, deps);
  370. expect(code).toBe(0);
  371. expect(calls.runs).toHaveLength(0);
  372. expect(calls.logs.join('\n')).toMatch(/git pull/);
  373. });
  374. });
  375. // ---------------------------------------------------------------------------
  376. // Beta signup offer — fires ONLY after a real, successful binary update.
  377. // (The hook itself gates on TTY + the once-per-machine stored choice; see
  378. // __tests__/beta-signup.test.ts. Here we pin WHEN the upgrade path invokes it.)
  379. // ---------------------------------------------------------------------------
  380. describe('runUpgrade beta signup offer', () => {
  381. function withSpy(deps: UpgradeDeps): { deps: UpgradeDeps; offered: () => number } {
  382. let n = 0;
  383. deps.offerBetaSignup = async () => { n += 1; };
  384. return { deps, offered: () => n };
  385. }
  386. it('offers after a successful npm upgrade', async () => {
  387. const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
  388. const { offered } = withSpy(deps);
  389. expect(await runUpgrade({}, deps)).toBe(0);
  390. expect(offered()).toBe(1);
  391. });
  392. it('does not offer on --check', async () => {
  393. const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
  394. const { offered } = withSpy(deps);
  395. expect(await runUpgrade({ check: true }, deps)).toBe(0);
  396. expect(offered()).toBe(0);
  397. });
  398. it('does not offer when already up to date', async () => {
  399. const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.9' });
  400. const { offered } = withSpy(deps);
  401. expect(await runUpgrade({}, deps)).toBe(0);
  402. expect(offered()).toBe(0);
  403. });
  404. it('does not offer when the upgrade fails', async () => {
  405. const { deps } = makeDeps(
  406. { method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' },
  407. 1 // npm exits non-zero
  408. );
  409. const { offered } = withSpy(deps);
  410. expect(await runUpgrade({}, deps)).toBe(1);
  411. expect(offered()).toBe(0);
  412. });
  413. it('does not offer on npx / source no-op paths', async () => {
  414. for (const method of [
  415. { kind: 'npx' } as const,
  416. { kind: 'source', root: '/dev/codegraph' } as const,
  417. ]) {
  418. const { deps } = makeDeps({ method, currentVersion: '0.9.8' });
  419. const { offered } = withSpy(deps);
  420. expect(await runUpgrade({}, deps)).toBe(0);
  421. expect(offered()).toBe(0);
  422. }
  423. });
  424. it('a throwing offer never fails the upgrade', async () => {
  425. const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
  426. deps.offerBetaSignup = async () => { throw new Error('boom'); };
  427. expect(await runUpgrade({}, deps)).toBe(0);
  428. });
  429. });
  430. // ---------------------------------------------------------------------------
  431. // Post-upgrade self-heal of installed agent surfaces
  432. // ---------------------------------------------------------------------------
  433. describe('post-upgrade refresh of installed agent surfaces', () => {
  434. it('runs `codegraph install --refresh` via the NEW binary after a successful npm upgrade', async () => {
  435. const { deps, calls } = makeDeps({
  436. method: { kind: 'npm', scope: 'global' },
  437. currentVersion: '0.9.8',
  438. hasCommand: (cmd) => cmd === 'codegraph',
  439. });
  440. const code = await runUpgrade({}, deps);
  441. expect(code).toBe(0);
  442. // The refresh is spawned AFTER the binary swap, so the fresh install
  443. // (with the current templates) does the writing — not this process.
  444. const last = calls.runs[calls.runs.length - 1];
  445. expect(last?.cmd).toBe('codegraph');
  446. expect(last?.args).toEqual(['install', '--refresh']);
  447. });
  448. it('runs the Windows .cmd launcher through cmd.exe', async () => {
  449. const { deps, calls } = makeDeps({
  450. method: { kind: 'npm', scope: 'global' },
  451. currentVersion: '0.9.8',
  452. platform: 'win32',
  453. hasCommand: (cmd) => cmd === 'codegraph',
  454. });
  455. const code = await runUpgrade({}, deps);
  456. expect(code).toBe(0);
  457. const last = calls.runs[calls.runs.length - 1];
  458. expect(last?.cmd).toBe('cmd.exe');
  459. expect(last?.args).toEqual(['/d', '/s', '/c', 'codegraph install --refresh']);
  460. });
  461. it('skips the refresh when `codegraph` is not resolvable on PATH', async () => {
  462. const { deps, calls } = makeDeps({
  463. method: { kind: 'npm', scope: 'global' },
  464. currentVersion: '0.9.8',
  465. // default hasCommand resolves only curl
  466. });
  467. const code = await runUpgrade({}, deps);
  468. expect(code).toBe(0);
  469. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  470. });
  471. it('a failing refresh warns but does not fail the upgrade', async () => {
  472. const { deps, calls } = makeDeps({
  473. method: { kind: 'npm', scope: 'global' },
  474. currentVersion: '0.9.8',
  475. hasCommand: (cmd) => cmd === 'codegraph',
  476. });
  477. deps.run = (cmd, args, env) => {
  478. calls.runs.push({ cmd, args, env });
  479. return cmd === 'codegraph' ? 1 : 0;
  480. };
  481. const code = await runUpgrade({}, deps);
  482. expect(code).toBe(0);
  483. expect(calls.logs.join('\n')).toMatch(/install --refresh/);
  484. });
  485. it('does not run after a failed upgrade', async () => {
  486. const { deps, calls } = makeDeps(
  487. {
  488. method: { kind: 'npm', scope: 'global' },
  489. currentVersion: '0.9.8',
  490. hasCommand: (cmd) => cmd === 'codegraph',
  491. },
  492. 1
  493. );
  494. const code = await runUpgrade({}, deps);
  495. expect(code).toBe(1);
  496. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  497. });
  498. it('respects the CODEGRAPH_NO_INSTALL_REFRESH kill-switch', async () => {
  499. process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
  500. try {
  501. const { deps, calls } = makeDeps({
  502. method: { kind: 'npm', scope: 'global' },
  503. currentVersion: '0.9.8',
  504. hasCommand: (cmd) => cmd === 'codegraph',
  505. });
  506. const code = await runUpgrade({}, deps);
  507. expect(code).toBe(0);
  508. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  509. } finally {
  510. delete process.env.CODEGRAPH_NO_INSTALL_REFRESH;
  511. }
  512. });
  513. it('skips the refresh when the version probe says a stale install shadows the new one', async () => {
  514. const { deps, calls } = makeDeps({
  515. method: { kind: 'npm', scope: 'global' },
  516. currentVersion: '0.9.8',
  517. hasCommand: (cmd) => cmd === 'codegraph',
  518. capture: () => ({ code: 0, stdout: '0.9.8\n' }), // PATH still serves the OLD version
  519. });
  520. const code = await runUpgrade({}, deps);
  521. expect(code).toBe(0);
  522. // Spawning `codegraph install --refresh` would execute the shadowed stale
  523. // binary — the exact staleness the refresh exists to heal.
  524. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  525. expect(calls.logs.join('\n')).toMatch(/run `codegraph install --refresh` once the PATH is fixed/);
  526. });
  527. });
  528. // ---------------------------------------------------------------------------
  529. // Post-upgrade version probe — does the PATH-resolved `codegraph` serve the
  530. // version we just installed, in THIS terminal?
  531. // ---------------------------------------------------------------------------
  532. describe('post-upgrade version probe', () => {
  533. const npmGlobal = { method: { kind: 'npm', scope: 'global' } as InstallMethod, currentVersion: '0.9.8' };
  534. it('match: confirms the same terminal already serves the new version', async () => {
  535. const { deps, calls } = makeDeps({
  536. ...npmGlobal,
  537. hasCommand: (c) => c === 'codegraph',
  538. capture: () => ({ code: 0, stdout: '0.9.9\n' }),
  539. });
  540. const code = await runUpgrade({}, deps);
  541. expect(code).toBe(0);
  542. expect(calls.captures).toEqual([{ cmd: 'codegraph', args: ['--version'] }]);
  543. const out = calls.logs.join('\n');
  544. expect(out).toMatch(/now reports v0\.9\.9/);
  545. expect(out).not.toMatch(/Open a new terminal/);
  546. });
  547. it('mismatch: warns that a shadowing install is still serving the old version', async () => {
  548. const { deps, calls } = makeDeps({
  549. ...npmGlobal,
  550. hasCommand: (c) => c === 'codegraph',
  551. capture: () => ({ code: 0, stdout: '0.9.8\n' }),
  552. });
  553. const code = await runUpgrade({}, deps);
  554. expect(code).toBe(0); // the upgrade itself succeeded — warn, don't fail
  555. const out = calls.logs.join('\n');
  556. expect(out).toMatch(/still reports an older version/);
  557. expect(out).toMatch(/shadowing/);
  558. expect(out).toMatch(/which -a codegraph/);
  559. });
  560. it('inconclusive: falls back to the soft new-terminal hint when codegraph is not on PATH', async () => {
  561. const { deps, calls } = makeDeps(npmGlobal); // hasCommand resolves only curl
  562. const code = await runUpgrade({}, deps);
  563. expect(code).toBe(0);
  564. expect(calls.captures).toHaveLength(0);
  565. expect(calls.logs.join('\n')).toMatch(/Open a new terminal/);
  566. });
  567. it('inconclusive: a failing or unparsable probe never warns about shadowing', async () => {
  568. const { deps, calls } = makeDeps({
  569. ...npmGlobal,
  570. hasCommand: (c) => c === 'codegraph',
  571. capture: () => ({ code: 0, stdout: 'something went wrong\n' }),
  572. });
  573. const code = await runUpgrade({}, deps);
  574. expect(code).toBe(0);
  575. const out = calls.logs.join('\n');
  576. expect(out).not.toMatch(/shadowing/);
  577. expect(out).toMatch(/Open a new terminal/);
  578. });
  579. it('parses the last non-empty line, so a runtime warning above the version is harmless', () => {
  580. const { deps } = makeDeps({
  581. ...npmGlobal,
  582. hasCommand: (c) => c === 'codegraph',
  583. capture: () => ({ code: 0, stdout: '(node:1) ExperimentalWarning: blah\nv0.9.9\n\n' }),
  584. });
  585. expect(verifyResolvedVersion('v0.9.9', deps)).toBe('match');
  586. });
  587. it('routes the probe through cmd.exe on Windows (.cmd launcher)', async () => {
  588. const { deps, calls } = makeDeps({
  589. ...npmGlobal,
  590. platform: 'win32',
  591. hasCommand: (c) => c === 'codegraph' || c === 'npm.cmd',
  592. capture: () => ({ code: 0, stdout: '0.9.9\r\n' }),
  593. });
  594. const code = await runUpgrade({}, deps);
  595. expect(code).toBe(0);
  596. expect(calls.captures).toEqual([{ cmd: 'cmd.exe', args: ['/d', '/s', '/c', 'codegraph --version'] }]);
  597. expect(calls.logs.join('\n')).toMatch(/now reports v0\.9\.9/);
  598. });
  599. it('skips the probe for npm-local installs — PATH serves a different copy', async () => {
  600. const { deps, calls } = makeDeps({
  601. method: { kind: 'npm', scope: 'local' },
  602. currentVersion: '0.9.8',
  603. hasCommand: (c) => c === 'codegraph',
  604. capture: () => ({ code: 0, stdout: '0.9.7\n' }),
  605. });
  606. const code = await runUpgrade({}, deps);
  607. expect(code).toBe(0);
  608. expect(calls.captures).toHaveLength(0);
  609. expect(calls.logs.join('\n')).not.toMatch(/shadowing/);
  610. });
  611. it('does not probe after a failed upgrade', async () => {
  612. const { deps, calls } = makeDeps(
  613. { ...npmGlobal, hasCommand: (c) => c === 'codegraph', capture: () => ({ code: 0, stdout: '0.9.9\n' }) },
  614. 1
  615. );
  616. const code = await runUpgrade({}, deps);
  617. expect(code).toBe(1);
  618. expect(calls.captures).toHaveLength(0);
  619. });
  620. });
  621. // ---------------------------------------------------------------------------
  622. // Re-index staleness — real index, real metadata stamp
  623. // ---------------------------------------------------------------------------
  624. describe('index extraction-version stamp / isIndexStale', () => {
  625. let dir: string;
  626. beforeEach(() => {
  627. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upgrade-stamp-'));
  628. });
  629. afterEach(() => {
  630. fs.rmSync(dir, { recursive: true, force: true });
  631. });
  632. it('stamps the current extraction version on full index and is not stale', async () => {
  633. fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n');
  634. const cg = await CodeGraph.init(dir, { index: false });
  635. // No index yet → not stale (nothing to refresh).
  636. expect(cg.isIndexStale()).toBe(false);
  637. await cg.indexAll();
  638. const info = cg.getIndexBuildInfo();
  639. expect(info.extractionVersion).toBe(EXTRACTION_VERSION);
  640. expect(typeof info.version).toBe('string');
  641. expect(cg.isIndexStale()).toBe(false);
  642. cg.destroy();
  643. });
  644. it('flags an index stamped by an older extraction version as stale', async () => {
  645. fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n');
  646. const cg = await CodeGraph.init(dir, { index: false });
  647. await cg.indexAll();
  648. // Simulate an index built by an older engine.
  649. (cg as unknown as { queries: { setMetadata(k: string, v: string): void } }).queries.setMetadata(
  650. 'indexed_with_extraction_version',
  651. String(EXTRACTION_VERSION - 1)
  652. );
  653. expect(cg.isIndexStale()).toBe(true);
  654. cg.destroy();
  655. });
  656. });