From b1d63610a3fab979e213ed498eac8f2aa21a4256 Mon Sep 17 00:00:00 2001 From: Phoebe-2419 Date: Sun, 18 Oct 2026 22:55:49 -0400 Subject: [PATCH] test(workspace): the watcher's polling tests wait until changes flow before they write chokidar starts a directory's watch only once it has read the directory, just before ready. On its polling backend that watch is fs.watchFile, whose first stat, run in libuv's threadpool, is what each later stat is compared with, so a file written before that stat is never reported. The case C test (mkdir -p covers missing parent dir at attach time) wrote one file at the first 25 ms poll after ready, and in a slow run on Linux it didn't hear it. Four more polling tests write the same way: the atomic save, the new file written twice, the one that checks normal traffic doesn't re-attach, and the one that attaches again after a re-attach whose close fails, which writes on the new watcher's ready. Each now waits for changesFlow before its first write: readyWithPolling runs it, so the two save tests take it, and the other three call it after their own ready waits. changesFlow's comments say the polling case. Co-Authored-By: Claude Opus 5.5 --- test/helpers/watcher-flow.ts | 15 +++++++++------ test/workspace-watcher.test.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/test/helpers/watcher-flow.ts b/test/helpers/watcher-flow.ts index 787c0704d..bf17aa8d0 100644 --- a/test/helpers/watcher-flow.ts +++ b/test/helpers/watcher-flow.ts @@ -1,6 +1,7 @@ /** - * The handshake a test of the native watcher makes before it writes what it - * means to hear: changesFlow, and the probe files it writes (isProbe). + * The handshake a watcher test makes, on either of chokidar's backends, before + * it writes what it means to hear: changesFlow, and the probe files it writes + * (isProbe). */ import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; @@ -29,10 +30,12 @@ export const isProbe = (path: string): boolean => basename(path).startsWith(PROB * one is added. So a file written in between isn't reported until a later * change in its directory, when chokidar reads the directory again, and a probe * reported in one directory can come from a stream made before another - * directory's watch. Each attempt writes a new probe in each directory still - * unreported and waits up to a second, since the next probe is that later - * change. The probes stay in the batches, and a test's results leave them out - * (isProbe). + * directory's watch. chokidar's polling backend starts a directory's poll just + * before `onReady` too, and the poll's first stat is what each later one is + * compared with, so a file written before that stat is never reported. Each + * attempt writes a new probe in each directory still unreported and waits up + * to a second, since the next probe is that later change. The probes stay in + * the batches, and a test's results leave them out (isProbe). */ export async function changesFlow(root: string, batches: FsChange[][], dirs: readonly string[]): Promise { const where = dirs.length ? dirs : ['']; diff --git a/test/workspace-watcher.test.ts b/test/workspace-watcher.test.ts index 38fb5a383..1b5e087e9 100644 --- a/test/workspace-watcher.test.ts +++ b/test/workspace-watcher.test.ts @@ -131,8 +131,9 @@ async function waitFor(predicate: () => boolean, message: string, timeoutMs = EV } /** - * A polling watcher, once chokidar's first scan is done. A change made before - * then can be part of the files chokidar starts from (ignoreInitial), and is + * A polling watcher, once chokidar's first scan is done and its poll reports + * changes (changesFlow). A change made before then can be part of the files + * chokidar starts from (ignoreInitial), or of the poll's first stat, and is * then never reported. If the watcher has reported an error and isn't ready * when the wait checks, the wait fails with that error, and a watcher that * doesn't get ready is stopped. @@ -144,6 +145,7 @@ async function readyWithPolling(): Promise<{ watcher: MountWatcher; batches: FsC try { await waitFor(() => ready || failed !== undefined, 'expected the watcher to be ready'); if (!ready) throw failed; + await changesFlow(tmp, collected.batches, []); } catch (error) { await collected.watcher.stop(); throw error; @@ -330,8 +332,10 @@ describe('MountWatcher lifecycle', () => { try { assert.ok(existsSync(nested), 'start() should have created the watched path'); // A file written before chokidar's first scan is done can be part of - // what it starts from (ignoreInitial), and then is never reported. + // what it starts from (ignoreInitial), or of the poll's first stat, just + // after onReady, and then is never reported. await waitFor(() => ready, 'onReady should have fired'); + await changesFlow(nested, batches, []); writeFileSync(join(nested, 'ticket.md'), 'hi'); await changeFor(batches, 'ticket.md'); } finally { @@ -455,6 +459,7 @@ describe('MountWatcher lifecycle', () => { try { await waitFor(() => ready, 'expected initial watcher ready'); + await changesFlow(tmp, batches, []); const file = join(tmp, 'normal.md'); writeFileSync(file, 'v1'); @@ -738,6 +743,7 @@ describe('MountWatcher lifecycle', () => { await (watcher as unknown as { reattach(): Promise }).reattach(); assert.ok(errors.some((e) => e.message === 'close failed'), "the re-attach reported its close's failure"); await waitFor(() => readyCount === 2, 'expected the watcher to attach again'); + await changesFlow(tmp, batches, []); writeFileSync(join(tmp, 'after.md'), 'x'); await changeFor(batches, 'after.md', 'created'); } finally {