GitNexus/gitnexus/test/integration/watch-filesystem.test.ts
Gergő Magyar 12763a40c8
fix(watch): await a watcher re-arm barrier so gitignore reloads cannot drop events (#3159)
* Increase CI timeout budget for flaky watch-filesystem test

* test(watch): include elapsed budget in waitFor timeout errors (#3156)

Make CI flake timeouts self-describing without raising the 90s ceiling, and cite the mcp/server-startup 15s/5s convention in the helper comment.

* fix(watch): await a watcher re-arm barrier after an ignore-rule reload

An ignore-rule reload re-armed the watcher with `watcher.add(repoPath)`,
which returns before the rescan it starts has finished and offers no signal
for that completion. A file the reload had just unignored was therefore still
unregistered when the call returned, and since `ignoreInitial` suppresses the
`add` that the in-flight rescan would emit, an immediate rewrite of that file
was dropped permanently. A standalone reproduction missed the rewrite 40/40
times on both chokidar 4.0.3 and 5.0.0.

Re-arm by arming a replacement watcher and awaiting its `ready` instead, which
is the only completion signal chokidar exposes (`ready` never fires twice on
one instance). The re-arm runs before the refresh, so a write that lands while
the replacement arms is still read by that refresh; the outgoing instance keeps
reporting until the swap, so no event window is dropped; and a replacement that
fails to arm leaves the working instance in place for the queue to retry. The
transient-watcher-error path now requests the same awaited re-arm rather than
re-arming inline ahead of its catch-up refresh.

This replaces the CI timeout increase from #3156, which treated the symptom:
the test was not slow, it was waiting for an event that never came.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(watch): drop restating comments and duplicated waitFor state

The re-arm error now uses the same cause-wrapping shape as ignore-control
reload, and the instant-rewrite test waits for both paths in one poll.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 11:25:51 +00:00

265 lines
9.9 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { startWatchFileLoop, type WatchFileLoop } from '../../src/cli/analyze-watch.js';
import { cleanupTempDir } from '../helpers/test-db.js';
const tempDirs: string[] = [];
const loops: WatchFileLoop[] = [];
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error(`timed out waiting for watcher event after ${timeoutMs}ms`);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
async function makeRepo(): Promise<string> {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-fs-'));
tempDirs.push(repo);
execFileSync('git', ['init', '-q'], { cwd: repo });
return repo;
}
afterEach(async () => {
await Promise.all(loops.splice(0).map((loop) => loop.close()));
await Promise.all(tempDirs.splice(0).map((dir) => cleanupTempDir(dir)));
});
describe('watch filesystem integration', () => {
it('fails startup and closes the watcher when the initial analysis fails', async () => {
const repo = await makeRepo();
const onError = vi.fn();
await expect(
startWatchFileLoop(
repo,
25,
async () => {
throw new Error('initial analysis failed');
},
onError,
),
).rejects.toThrow('initial analysis failed');
expect(onError).not.toHaveBeenCalled();
});
it('never enqueues analyzer-owned .gitnexus writes created by the initial refresh', async () => {
const repo = await makeRepo();
const batches: string[][] = [];
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => {
batches.push([...paths]);
if (paths.length === 0) {
await fs.mkdir(path.join(repo, '.gitnexus'), { recursive: true });
await fs.writeFile(path.join(repo, '.gitnexus', 'gitnexus.json'), '{}\n', 'utf8');
await fs.writeFile(path.join(repo, '.gitnexus', 'lbug'), 'index bytes', 'utf8');
}
},
(error) => {
throw error;
},
);
loops.push(loop);
await new Promise((resolve) => setTimeout(resolve, 200));
await loop.waitForIdle();
expect(batches).toEqual([[]]);
});
it('coalesces indexed add/change/rename/delete events and stops cleanly', async () => {
const repo = await makeRepo();
const batches: string[][] = [];
const loop = await startWatchFileLoop(
repo,
30,
async (paths) => batches.push([...paths]),
(error) => {
throw error;
},
);
loops.push(loop);
expect(batches).toEqual([[]]);
await fs.writeFile(path.join(repo, 'README.md'), '# One', 'utf8');
await fs.writeFile(path.join(repo, 'src.ts'), 'export const one = 1;', 'utf8');
await fs.writeFile(path.join(repo, 'src.ts'), 'export const one = 2;', 'utf8');
await waitFor(() => batches.flat().includes('README.md') && batches.flat().includes('src.ts'));
await fs.rename(path.join(repo, 'src.ts'), path.join(repo, 'renamed.ts'));
await waitFor(() => batches.flat().includes('renamed.ts'));
await fs.rm(path.join(repo, 'renamed.ts'));
await waitFor(() => batches.flat().filter((entry) => entry === 'renamed.ts').length >= 2);
expect(batches.flat()).toEqual(expect.arrayContaining(['README.md', 'src.ts', 'renamed.ts']));
await loop.close();
loops.pop();
const countAfterClose = batches.length;
await fs.writeFile(path.join(repo, 'after-close.ts'), 'export {};', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 150));
expect(batches).toHaveLength(countAfterClose);
});
it('queues edits during refresh, recovers after failure, and ignores external symlinks', async () => {
const repo = await makeRepo();
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-outside-'));
tempDirs.push(outside);
await fs.symlink(
outside,
path.join(repo, 'external'),
process.platform === 'win32' ? 'junction' : 'dir',
);
const successful: string[][] = [];
const errors: string[][] = [];
let failNext = false;
let releaseRefresh: (() => void) | undefined;
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => {
if (failNext) {
failNext = false;
throw new Error('injected refresh failure');
}
successful.push([...paths]);
if (paths.includes('first.ts')) {
await new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
}
},
(_error, paths) => errors.push([...paths]),
);
loops.push(loop);
await fs.writeFile(path.join(repo, 'first.ts'), 'export const first = 1;', 'utf8');
await waitFor(() => releaseRefresh !== undefined);
await fs.writeFile(path.join(repo, 'during.ts'), 'export const during = 1;', 'utf8');
releaseRefresh!();
await waitFor(() => successful.flat().includes('during.ts'));
failNext = true;
await fs.writeFile(path.join(repo, 'fails.ts'), 'export const fail = 1;', 'utf8');
await waitFor(() => errors.length === 1);
await waitFor(() => successful.flat().includes('fails.ts'));
await fs.writeFile(path.join(repo, 'retry.ts'), 'export const retry = 1;', 'utf8');
await waitFor(() => successful.flat().includes('retry.ts'));
const beforeExternal = successful.length + errors.length;
await fs.writeFile(path.join(outside, 'outside.ts'), 'export const outside = 1;', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(successful.length + errors.length).toBe(beforeExternal);
});
it('reloads gitignore rules before processing subsequent file events', async () => {
const repo = await makeRepo();
await fs.writeFile(path.join(repo, '.gitignore'), 'blocked.ts\n', 'utf8');
const batches: string[][] = [];
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
(error) => {
throw error;
},
);
loops.push(loop);
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 1;', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(batches.flat()).not.toContain('blocked.ts');
await fs.writeFile(path.join(repo, '.gitignore'), '', 'utf8');
await waitFor(() => batches.flat().includes('.gitignore'));
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 2;', 'utf8');
await waitFor(() => batches.flat().includes('blocked.ts'));
});
it('reports writes issued the instant a gitignore reload re-arms the watcher', async () => {
const repo = await makeRepo();
await fs.writeFile(path.join(repo, '.gitignore'), 'blocked.ts\n', 'utf8');
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 1;', 'utf8');
await fs.writeFile(path.join(repo, 'tracked.ts'), 'export const tracked = 1;', 'utf8');
const batches: string[][] = [];
let rewritten = false;
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => {
batches.push([...paths]);
// Writing from inside the refresh puts these rewrites right after the
// re-arm returns. Polling from the test body instead would leave enough
// slack for a watcher that is not armed yet to look armed.
if (paths.includes('.gitignore') && !rewritten) {
rewritten = true;
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 2;', 'utf8');
await fs.writeFile(path.join(repo, 'tracked.ts'), 'export const tracked = 2;', 'utf8');
}
},
(error) => {
throw error;
},
);
loops.push(loop);
await fs.writeFile(path.join(repo, '.gitignore'), '', 'utf8');
await waitFor(
() => batches.flat().includes('blocked.ts') && batches.flat().includes('tracked.ts'),
);
});
it('keeps the last valid ignore predicate after an oversized reload and later recovers', async () => {
const repo = await makeRepo();
await fs.writeFile(path.join(repo, '.gitignore'), 'blocked.ts\n', 'utf8');
const batches: string[][] = [];
const errors: string[][] = [];
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
(_error, paths) => errors.push([...paths]),
);
loops.push(loop);
await fs.writeFile(path.join(repo, '.gitignore'), 'x'.repeat(1024 * 1024 + 1), 'utf8');
await waitFor(() => errors.flat().includes('.gitignore'));
await waitFor(() => errors.length >= 2);
await fs.writeFile(path.join(repo, 'other.ts'), 'export const other = 1;', 'utf8');
await waitFor(() => errors.flat().includes('other.ts'));
expect(batches.flat()).not.toContain('other.ts');
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 1;', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(batches.flat()).not.toContain('blocked.ts');
await fs.writeFile(path.join(repo, '.gitignore'), '', 'utf8');
await waitFor(() => batches.flat().includes('.gitignore'));
await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 2;', 'utf8');
await waitFor(() => batches.flat().includes('blocked.ts'));
});
it('observes root control files even when gitignore excludes them', async () => {
const repo = await makeRepo();
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexusrc\n', 'utf8');
const batches: string[][] = [];
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
(error) => {
throw error;
},
);
loops.push(loop);
await fs.writeFile(path.join(repo, '.gitnexusrc'), '{}\n', 'utf8');
await waitFor(() => batches.flat().includes('.gitnexusrc'));
});
});