mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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>
265 lines
9.9 KiB
TypeScript
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'));
|
|
});
|
|
});
|