Files
codegraph/__tests__/git-index-currency.test.ts
T
danusha2345andClaude Opus 5.5 b1e6c9767d test: move the test workspace to Vitest 4 and align the Vite toolchain
The repo ran two Vite stacks. ui/ builds the viewer with Vite 7 and
vite-plugin-svelte 6, while Vitest 2 at the root kept Vite 5.4 and plugin 4 with
their own esbuild, whose dev tree carried the critical audit findings. The move
leaves one stack.

Tests now run as test.projects in vitest.config.mts, so vitest.workspace.mts goes
away: engine extends the base (env, global setup, home sandbox), ui stands alone
with the Svelte plugin, jsdom and the browser conditions. The WASM flags move to
test.execArgv, because Vitest 4 ignores poolOptions.forks.execArgv with a
deprecation line and the #1779 worker death comes back. git-index-currency injects
its failures through the mocks' own vi.fn: under Vitest 4 a spy on a mocked module
is that vi.fn, so the captured real function recursed into itself, and
restoreAllMocks no longer resets one, so the injection leaked into later tests.
devEngines sets Node 22.12 as the floor for working in the repo, which is what
Vite 7 asks for; engines, what users run, is unchanged.

Changes on top of the contributor's commits:
- The lockfile is regenerated from main's, not taken from the PR: the PR's would
  have put devalue, postcss and ignore back below the fixes in issues 3044 and
  3050, and restored the @types/better-sqlite3 that 3044 dropped. ui's packages
  hoist to the root at the versions it already built with, and rollup stays.
- testTimeout and hookTimeout rise to 30 s and 60 s. Since Vitest 3 a synchronous
  test or hook that outlasts its limit fails; under Vitest 2 the timer could not
  fire until the body returned, so a body blocked in spawnSync never timed out. A
  6 s synchronous test passes on 2.1.9 and fails on 4.1.11, and an 11 s hook does
  the same and skips its tests. The first full run lost frontload-hook to this,
  the contributor's lost two bundle-launcher listings, and 31 tests passed 4 s on
  a quiet machine, so on a loaded one they would all flake.
- A new test reads the WASM flags from the worker's own command line, since the
  config can stop reaching workers with no failure, and a Vitest 5 move could
  repeat that.
- CONTRIBUTING and AGENTS state the new floor and the projects setup; two test
  files name TestProject, which replaced the removed GlobalSetupContext type.
- Left out as out of scope: a trailing blank line in ui/src/lib/trail-codec.ts.

Validated on Windows, Node 22.22, npm 10.9:
- what ships is unchanged: npm ci --omit=dev, the bundle's step, gives 193 files
  byte-identical to main's; npm run build:lib gives 171 byte-identical files;
  dist/viewer matches main's once Svelte's scope hashes, which come from the
  checkout path, are masked (main built in two folders differs the same way).
- every test is kept: at cd04b21a vitest list finds 9,719 tests on main (9,701
  engine, 18 ui) and those plus the new guard here, each in the same project, none
  twice.
- npm's own checks across Node versions: from 22.12 up, devEngines passes and no
  package warns EBADENGINE, the same as main; below it npm ci and npm run stop with
  EBADDEVENGINES. Node 20 could not run from source already, and Vite 7 rejects
  22.5 to 22.11. npm ci logs the one deprecation main logs. release.yml's lock sync
  changes only the version fields.
- dev tree audit: main 10 findings with 2 critical, here 7 with none critical;
  production stays at 0.
- red to green: main's git-index-currency fails 7 of 13 under Vitest 4; putting
  the flags back under poolOptions turns the guard red.

Original PR #1667 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:31:15 -05:00

172 lines
9.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as cp from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
// Wrap only I/O entry points for deterministic failure injection; all other
// calls, files, parser work and SQLite remain real.
vi.mock('child_process', async importOriginal => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});
vi.mock('fs', async importOriginal => {
const actual = await importOriginal<typeof import('fs')>();
// openSync too: a bounded source reader opens a descriptor instead of
// calling readFileSync, and the injected read failure must reach either.
return { ...actual, readFileSync: vi.fn(actual.readFileSync), openSync: vi.fn(actual.openSync) };
});
// The real entry points, for the injecting implementations to fall through to
// and for afterEach to reinstate. Under Vitest 4 `vi.spyOn` on a mocked
// module's export returns the same `vi.fn`, so reading `cp.execFileSync` inside
// a test and calling it from the injected implementation recurses into itself,
// and `vi.restoreAllMocks()` no longer resets a `vi.fn`'s implementation.
const actualCp = await vi.importActual<typeof import('child_process')>('child_process');
const actualFs = await vi.importActual<typeof import('fs')>('fs');
const injectExecFileSync = (impl: (real: typeof cp.execFileSync, ...args: any[]) => unknown) =>
vi.mocked(cp.execFileSync).mockImplementation(((...args: any[]) => impl(actualCp.execFileSync, ...args)) as typeof cp.execFileSync);
const injectReadFileSync = (impl: (real: typeof fs.readFileSync, ...args: any[]) => unknown) =>
vi.mocked(fs.readFileSync).mockImplementation(((...args: any[]) => impl(actualFs.readFileSync, ...args)) as typeof fs.readFileSync);
const injectOpenSync = (impl: (real: typeof fs.openSync, ...args: any[]) => unknown) =>
vi.mocked(fs.openSync).mockImplementation(((...args: any[]) => impl(actualFs.openSync, ...args)) as typeof fs.openSync);
const reinstateIo = () => {
vi.mocked(cp.execFileSync).mockImplementation(actualCp.execFileSync as typeof cp.execFileSync);
vi.mocked(fs.readFileSync).mockImplementation(actualFs.readFileSync as typeof fs.readFileSync);
vi.mocked(fs.openSync).mockImplementation(actualFs.openSync as typeof fs.openSync);
};
describe('git index currency across commits and restores (#1829)', () => {
let root: string;
let cg: CodeGraph;
const git = (...args: string[]) => cp.execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
const write = (name: string, symbol: string) => fs.writeFileSync(path.join(root, name), `export function ${symbol}() { return 1; }\n`);
const commit = () => { git('add', '-A'); git('commit', '-m', 'change'); return git('rev-parse', 'HEAD').trim(); };
const metadata = () => (cg as any).queries;
const symbols = (name: string) => cg.searchNodes(name).map(r => r.node.name);
const clean = () => expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] });
beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-git-currency-'));
git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Test');
fs.writeFileSync(path.join(root, '.gitignore'), '.codegraph/\n');
write('source.ts', 'original'); commit();
cg = CodeGraph.initSync(root);
expect((await cg.indexAll()).success).toBe(true);
});
afterEach(() => { reinstateIo(); vi.restoreAllMocks(); cg?.close(); fs.rmSync(root, { recursive: true, force: true }); });
it.each(['index', 'sync', 'scoped'] as const)('sees a restored indexed dirty edit after %s', async mode => {
write('source.ts', 'dirtyVersion');
if (mode === 'index') await cg.indexAll();
else await cg.sync(mode === 'scoped' ? { paths: ['source.ts'] } : {});
expect(symbols('dirtyVersion')).toContain('dirtyVersion');
git('restore', 'source.ts');
expect(git('status', '--porcelain')).toBe('');
// Reopen proves that dirty candidates survive beyond one engine instance.
cg.close(); cg = CodeGraph.openSync(root);
expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
await cg.sync();
expect(symbols('original')).toContain('original');
expect(symbols('dirtyVersion')).not.toContain('dirtyVersion'); clean();
});
it.each(['index', 'sync'] as const)('does not claim a commit made during %s maintenance', async mode => {
const oldHead = git('rev-parse', 'HEAD').trim();
write('source.ts', 'firstEdit');
const db = (cg as any).db;
const maintain = db.runMaintenance.bind(db);
const spy = vi.spyOn(db, 'runMaintenance').mockImplementationOnce(async () => {
write('source.ts', 'lateCommit'); commit();
return maintain();
});
if (mode === 'index') await cg.indexAll(); else await cg.sync();
expect(spy).toHaveBeenCalledOnce();
expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
await cg.sync(); expect(symbols('lateCommit')).toContain('lateCommit'); clean();
});
it('handles non-ASCII and quoted committed paths without git text escaping', async () => {
const names = ['тест.ts', 'space name.ts'];
if (process.platform !== 'win32') names.push('quote"name.ts');
for (const file of names) write(file, 'newSymbol');
commit();
expect(cg.getChangedFiles().added.sort()).toEqual(names.sort());
await cg.sync(); clean();
fs.renameSync(path.join(root, 'тест.ts'), path.join(root, 'renamed.ts')); commit();
expect(cg.getChangedFiles()).toEqual({ added: ['renamed.ts'], modified: [], removed: ['тест.ts'] });
});
it('falls back when git diff fails instead of claiming a clean index', () => {
write('new.ts', 'newSymbol'); commit();
let injected = 0;
injectExecFileSync((real, file: string, args: string[], options: any) => {
if (file === 'git' && args[0] === 'diff') { injected++; throw new Error('Injected git diff timeout'); }
return real(file, args, options);
});
expect(cg.getChangedFiles().added).toEqual(['new.ts']);
expect(injected).toBeGreaterThan(0);
});
it('does not advance the commit stamp after a scoped sync', async () => {
const oldHead = metadata().getMetadata('indexed_at_commit');
write('one.ts', 'one'); write('two.ts', 'two'); commit();
await cg.sync({ paths: ['one.ts'] });
expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
expect(cg.getChangedFiles().added).toEqual(['two.ts']);
await cg.sync(); clean();
});
it('retains a restored dirty path when a later scoped sync touches another file', async () => {
write('source.ts', 'dirtyVersion'); await cg.sync();
git('restore', 'source.ts'); write('other.ts', 'other');
await cg.sync({ paths: ['other.ts'] });
expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
await cg.sync(); expect(symbols('original')).toContain('original'); clean();
});
it('does not call a recreated committed deletion removed when the current bytes match the DB', async () => {
fs.unlinkSync(path.join(root, 'source.ts')); commit();
write('source.ts', 'original');
clean();
write('source.ts', 'replacement');
expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
await cg.sync(); expect(symbols('replacement')).toContain('replacement'); clean();
});
it('retains deleted untracked files as candidates after they were indexed', async () => {
write('untracked.ts', 'temporary'); await cg.sync();
fs.unlinkSync(path.join(root, 'untracked.ts'));
expect(cg.getChangedFiles().removed).toEqual(['untracked.ts']);
await cg.sync(); expect(symbols('temporary')).not.toContain('temporary'); clean();
});
it('never advances freshness after a failed full index', async () => {
write('new.ts', 'newSymbol'); commit();
const controller = new AbortController(); controller.abort();
expect((await cg.indexAll({ signal: controller.signal })).success).toBe(false);
expect(cg.getChangedFiles().added).toEqual(['new.ts']);
});
it('keeps a committed path pending when sync cannot read it', async () => {
write('new.ts', 'newSymbol'); commit();
// Sync reads a source file through a bounded reader that opens a
// descriptor (#1910), so the failure is injected at openSync as well as
// readFileSync: it must reach whichever one the read goes through.
let injected = 0;
const failNewTs = (real: (...args: any[]) => unknown, file: any, ...args: any[]) => {
if (String(file) === path.join(root, 'new.ts')) { injected++; throw new Error('Injected transient read error'); }
return real(file, ...args);
};
injectReadFileSync(failNewTs);
injectOpenSync(failNewTs);
await cg.sync();
expect(injected).toBeGreaterThan(0);
expect(symbols('newSymbol')).not.toContain('newSymbol');
reinstateIo();
expect(cg.getChangedFiles().added).toEqual(['new.ts']);
await cg.sync(); clean();
});
});