upgrade.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  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. // Post-upgrade self-heal of installed agent surfaces
  377. // ---------------------------------------------------------------------------
  378. describe('post-upgrade refresh of installed agent surfaces', () => {
  379. it('runs `codegraph install --refresh` via the NEW binary after a successful npm upgrade', async () => {
  380. const { deps, calls } = makeDeps({
  381. method: { kind: 'npm', scope: 'global' },
  382. currentVersion: '0.9.8',
  383. hasCommand: (cmd) => cmd === 'codegraph',
  384. });
  385. const code = await runUpgrade({}, deps);
  386. expect(code).toBe(0);
  387. // The refresh is spawned AFTER the binary swap, so the fresh install
  388. // (with the current templates) does the writing — not this process.
  389. const last = calls.runs[calls.runs.length - 1];
  390. expect(last?.cmd).toBe('codegraph');
  391. expect(last?.args).toEqual(['install', '--refresh']);
  392. });
  393. it('runs the Windows .cmd launcher through cmd.exe', async () => {
  394. const { deps, calls } = makeDeps({
  395. method: { kind: 'npm', scope: 'global' },
  396. currentVersion: '0.9.8',
  397. platform: 'win32',
  398. hasCommand: (cmd) => cmd === 'codegraph',
  399. });
  400. const code = await runUpgrade({}, deps);
  401. expect(code).toBe(0);
  402. const last = calls.runs[calls.runs.length - 1];
  403. expect(last?.cmd).toBe('cmd.exe');
  404. expect(last?.args).toEqual(['/d', '/s', '/c', 'codegraph install --refresh']);
  405. });
  406. it('skips the refresh when `codegraph` is not resolvable on PATH', async () => {
  407. const { deps, calls } = makeDeps({
  408. method: { kind: 'npm', scope: 'global' },
  409. currentVersion: '0.9.8',
  410. // default hasCommand resolves only curl
  411. });
  412. const code = await runUpgrade({}, deps);
  413. expect(code).toBe(0);
  414. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  415. });
  416. it('a failing refresh warns but does not fail the upgrade', async () => {
  417. const { deps, calls } = makeDeps({
  418. method: { kind: 'npm', scope: 'global' },
  419. currentVersion: '0.9.8',
  420. hasCommand: (cmd) => cmd === 'codegraph',
  421. });
  422. deps.run = (cmd, args, env) => {
  423. calls.runs.push({ cmd, args, env });
  424. return cmd === 'codegraph' ? 1 : 0;
  425. };
  426. const code = await runUpgrade({}, deps);
  427. expect(code).toBe(0);
  428. expect(calls.logs.join('\n')).toMatch(/install --refresh/);
  429. });
  430. it('does not run after a failed upgrade', async () => {
  431. const { deps, calls } = makeDeps(
  432. {
  433. method: { kind: 'npm', scope: 'global' },
  434. currentVersion: '0.9.8',
  435. hasCommand: (cmd) => cmd === 'codegraph',
  436. },
  437. 1
  438. );
  439. const code = await runUpgrade({}, deps);
  440. expect(code).toBe(1);
  441. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  442. });
  443. it('respects the CODEGRAPH_NO_INSTALL_REFRESH kill-switch', async () => {
  444. process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
  445. try {
  446. const { deps, calls } = makeDeps({
  447. method: { kind: 'npm', scope: 'global' },
  448. currentVersion: '0.9.8',
  449. hasCommand: (cmd) => cmd === 'codegraph',
  450. });
  451. const code = await runUpgrade({}, deps);
  452. expect(code).toBe(0);
  453. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  454. } finally {
  455. delete process.env.CODEGRAPH_NO_INSTALL_REFRESH;
  456. }
  457. });
  458. it('skips the refresh when the version probe says a stale install shadows the new one', async () => {
  459. const { deps, calls } = makeDeps({
  460. method: { kind: 'npm', scope: 'global' },
  461. currentVersion: '0.9.8',
  462. hasCommand: (cmd) => cmd === 'codegraph',
  463. capture: () => ({ code: 0, stdout: '0.9.8\n' }), // PATH still serves the OLD version
  464. });
  465. const code = await runUpgrade({}, deps);
  466. expect(code).toBe(0);
  467. // Spawning `codegraph install --refresh` would execute the shadowed stale
  468. // binary — the exact staleness the refresh exists to heal.
  469. expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
  470. expect(calls.logs.join('\n')).toMatch(/run `codegraph install --refresh` once the PATH is fixed/);
  471. });
  472. });
  473. // ---------------------------------------------------------------------------
  474. // Post-upgrade version probe — does the PATH-resolved `codegraph` serve the
  475. // version we just installed, in THIS terminal?
  476. // ---------------------------------------------------------------------------
  477. describe('post-upgrade version probe', () => {
  478. const npmGlobal = { method: { kind: 'npm', scope: 'global' } as InstallMethod, currentVersion: '0.9.8' };
  479. it('match: confirms the same terminal already serves the new version', async () => {
  480. const { deps, calls } = makeDeps({
  481. ...npmGlobal,
  482. hasCommand: (c) => c === 'codegraph',
  483. capture: () => ({ code: 0, stdout: '0.9.9\n' }),
  484. });
  485. const code = await runUpgrade({}, deps);
  486. expect(code).toBe(0);
  487. expect(calls.captures).toEqual([{ cmd: 'codegraph', args: ['--version'] }]);
  488. const out = calls.logs.join('\n');
  489. expect(out).toMatch(/now reports v0\.9\.9/);
  490. expect(out).not.toMatch(/Open a new terminal/);
  491. });
  492. it('mismatch: warns that a shadowing install is still serving the old version', async () => {
  493. const { deps, calls } = makeDeps({
  494. ...npmGlobal,
  495. hasCommand: (c) => c === 'codegraph',
  496. capture: () => ({ code: 0, stdout: '0.9.8\n' }),
  497. });
  498. const code = await runUpgrade({}, deps);
  499. expect(code).toBe(0); // the upgrade itself succeeded — warn, don't fail
  500. const out = calls.logs.join('\n');
  501. expect(out).toMatch(/still reports an older version/);
  502. expect(out).toMatch(/shadowing/);
  503. expect(out).toMatch(/which -a codegraph/);
  504. });
  505. it('inconclusive: falls back to the soft new-terminal hint when codegraph is not on PATH', async () => {
  506. const { deps, calls } = makeDeps(npmGlobal); // hasCommand resolves only curl
  507. const code = await runUpgrade({}, deps);
  508. expect(code).toBe(0);
  509. expect(calls.captures).toHaveLength(0);
  510. expect(calls.logs.join('\n')).toMatch(/Open a new terminal/);
  511. });
  512. it('inconclusive: a failing or unparsable probe never warns about shadowing', async () => {
  513. const { deps, calls } = makeDeps({
  514. ...npmGlobal,
  515. hasCommand: (c) => c === 'codegraph',
  516. capture: () => ({ code: 0, stdout: 'something went wrong\n' }),
  517. });
  518. const code = await runUpgrade({}, deps);
  519. expect(code).toBe(0);
  520. const out = calls.logs.join('\n');
  521. expect(out).not.toMatch(/shadowing/);
  522. expect(out).toMatch(/Open a new terminal/);
  523. });
  524. it('parses the last non-empty line, so a runtime warning above the version is harmless', () => {
  525. const { deps } = makeDeps({
  526. ...npmGlobal,
  527. hasCommand: (c) => c === 'codegraph',
  528. capture: () => ({ code: 0, stdout: '(node:1) ExperimentalWarning: blah\nv0.9.9\n\n' }),
  529. });
  530. expect(verifyResolvedVersion('v0.9.9', deps)).toBe('match');
  531. });
  532. it('routes the probe through cmd.exe on Windows (.cmd launcher)', async () => {
  533. const { deps, calls } = makeDeps({
  534. ...npmGlobal,
  535. platform: 'win32',
  536. hasCommand: (c) => c === 'codegraph' || c === 'npm.cmd',
  537. capture: () => ({ code: 0, stdout: '0.9.9\r\n' }),
  538. });
  539. const code = await runUpgrade({}, deps);
  540. expect(code).toBe(0);
  541. expect(calls.captures).toEqual([{ cmd: 'cmd.exe', args: ['/d', '/s', '/c', 'codegraph --version'] }]);
  542. expect(calls.logs.join('\n')).toMatch(/now reports v0\.9\.9/);
  543. });
  544. it('skips the probe for npm-local installs — PATH serves a different copy', async () => {
  545. const { deps, calls } = makeDeps({
  546. method: { kind: 'npm', scope: 'local' },
  547. currentVersion: '0.9.8',
  548. hasCommand: (c) => c === 'codegraph',
  549. capture: () => ({ code: 0, stdout: '0.9.7\n' }),
  550. });
  551. const code = await runUpgrade({}, deps);
  552. expect(code).toBe(0);
  553. expect(calls.captures).toHaveLength(0);
  554. expect(calls.logs.join('\n')).not.toMatch(/shadowing/);
  555. });
  556. it('does not probe after a failed upgrade', async () => {
  557. const { deps, calls } = makeDeps(
  558. { ...npmGlobal, hasCommand: (c) => c === 'codegraph', capture: () => ({ code: 0, stdout: '0.9.9\n' }) },
  559. 1
  560. );
  561. const code = await runUpgrade({}, deps);
  562. expect(code).toBe(1);
  563. expect(calls.captures).toHaveLength(0);
  564. });
  565. });
  566. // ---------------------------------------------------------------------------
  567. // Re-index staleness — real index, real metadata stamp
  568. // ---------------------------------------------------------------------------
  569. describe('index extraction-version stamp / isIndexStale', () => {
  570. let dir: string;
  571. beforeEach(() => {
  572. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upgrade-stamp-'));
  573. });
  574. afterEach(() => {
  575. fs.rmSync(dir, { recursive: true, force: true });
  576. });
  577. it('stamps the current extraction version on full index and is not stale', async () => {
  578. fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n');
  579. const cg = await CodeGraph.init(dir, { index: false });
  580. // No index yet → not stale (nothing to refresh).
  581. expect(cg.isIndexStale()).toBe(false);
  582. await cg.indexAll();
  583. const info = cg.getIndexBuildInfo();
  584. expect(info.extractionVersion).toBe(EXTRACTION_VERSION);
  585. expect(typeof info.version).toBe('string');
  586. expect(cg.isIndexStale()).toBe(false);
  587. cg.destroy();
  588. });
  589. it('flags an index stamped by an older extraction version as stale', async () => {
  590. fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n');
  591. const cg = await CodeGraph.init(dir, { index: false });
  592. await cg.indexAll();
  593. // Simulate an index built by an older engine.
  594. (cg as unknown as { queries: { setMetadata(k: string, v: string): void } }).queries.setMetadata(
  595. 'indexed_with_extraction_version',
  596. String(EXTRACTION_VERSION - 1)
  597. );
  598. expect(cg.isIndexStale()).toBe(true);
  599. cg.destroy();
  600. });
  601. });