From f29e6749ee4c1e934a534a203e193f2440c98ce3 Mon Sep 17 00:00:00 2001 From: Phoebe-2419 Date: Sun, 18 Oct 2026 20:32:07 -0400 Subject: [PATCH] test(workspace): the `./` root watcher test waits until changes flow before it writes On macOS, `fs.watch` of a directory returns before libuv has made the FSEventStream that reports it, and that stream starts "since now", so a file written in between is reported only at a later change in its directory (by our reading of libuv's src/unix/fsevents.c). The test wrote one file right after onReady and nothing after it, so on macOS CI with Node 24 it twice heard nothing in its 10 s wait. changesFlow, the handshake #492 gave the watcher's own tests, moves unchanged into test/helpers/watcher-flow.ts with its probe filter, and the test waits for it before its write. Co-Authored-By: Claude Opus 5.5 --- test/helpers/watcher-flow.ts | 50 +++++++++++++++++++++++ test/workspace-nested-mount-roots.test.ts | 13 ++++-- test/workspace-watcher.test.ts | 38 +---------------- 3 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 test/helpers/watcher-flow.ts diff --git a/test/helpers/watcher-flow.ts b/test/helpers/watcher-flow.ts new file mode 100644 index 000000000..787c0704d --- /dev/null +++ b/test/helpers/watcher-flow.ts @@ -0,0 +1,50 @@ +/** + * 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). + */ +import assert from 'node:assert/strict'; +import { writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import type { FsChange } from '../../src/modules/workspace/watcher.js'; + +/** How long changesFlow waits in all, as long as a watcher test waits for an event. */ +const EVENT_MS = 10_000; + +async function wait(ms: number) { + await new Promise(r => setTimeout(r, ms)); +} + +// The probe files changesFlow writes, which a test's results leave out by name. +const PROBE_PREFIX = 'probe-'; +export const isProbe = (path: string): boolean => basename(path).startsWith(PROBE_PREFIX); + +/** + * Waits until the watcher reports a change in each of `dirs`, relative to the + * mount's root `root` (the root itself when `dirs` names none), so what a test + * writes there next is written while changes are delivered. `onReady` says + * chokidar's first scan is done, not that the platform delivers events yet. By + * our reading of libuv, on macOS `fs.watch` of a directory returns before + * libuv's own thread has made the FSEventStream that reports it, a stream that + * starts "since now" and is made again for every watched directory each time + * 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). + */ +export async function changesFlow(root: string, batches: FsChange[][], dirs: readonly string[]): Promise { + const where = dirs.length ? dirs : ['']; + const start = Date.now(); + const left = () => Date.now() - start < EVENT_MS; + const reportedIn = (dir: string) => batches.flat().some((c) => isProbe(c.path) && dirname(c.path) === (dir || '.')); + for (let n = 0; left(); n++) { + for (const dir of where) if (!reportedIn(dir)) writeFileSync(join(root, dir, `${PROBE_PREFIX}${n}.txt`), String(n)); + for (const attempt = Date.now(); Date.now() - attempt < 1_000 && left(); await wait(25)) { + if (where.every(reportedIn)) return; + } + } + const seen = [...new Set(batches.flat().map((c) => c.path))]; + assert.fail(`expected the watcher to report a probe in each of ${where.map((d) => d || '.').join(', ')}; it reported ${seen.join(', ') || 'nothing'}`); +} diff --git a/test/workspace-nested-mount-roots.test.ts b/test/workspace-nested-mount-roots.test.ts index 8316db73e..7bea61c2b 100644 --- a/test/workspace-nested-mount-roots.test.ts +++ b/test/workspace-nested-mount-roots.test.ts @@ -25,6 +25,7 @@ import { WorkspaceModule, WorkspaceReadError } from '../src/modules/workspace/in import { checkDir, readPath, type MountView } from '../src/modules/workspace/observe.js'; import { EffectFailed, unlinkContained, writeContained } from '../src/modules/workspace/effects.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; +import { changesFlow, isProbe } from './helpers/watcher-flow.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; @@ -741,15 +742,19 @@ describe('a watcher of a mount configured as a relative path', () => { const env = new Env(t); mkdirSync(env.at('m')); const path = `./${relative(process.cwd(), env.at('m'))}`; - const changes: FsChange[] = []; + const batches: FsChange[][] = []; let ready = false; - const watcher = new MountWatcher({ name: 'm', path, mode: 'read-write', watch: 'always', watchDebounceMs: 50 }, (batch) => changes.push(...batch), { onReady: () => { ready = true; } }); + const watcher = new MountWatcher({ name: 'm', path, mode: 'read-write', watch: 'always', watchDebounceMs: 50 }, (batch) => batches.push(batch), { onReady: () => { ready = true; } }); watcher.start(); t.after(() => watcher.stop()); for (let i = 0; i < 400 && !ready; i++) await new Promise((r) => setTimeout(r, 25)); assert.equal(ready, true, 'the watcher is ready'); + // On macOS a write right after onReady can come before the platform + // delivers events, and goes unreported, so the test waits until they flow. + await changesFlow(env.at('m'), batches, []); writeFileSync(env.at('m/x.txt'), 'x\n'); - for (let i = 0; i < 400 && !changes.some((c) => c.path === 'x.txt'); i++) await new Promise((r) => setTimeout(r, 25)); - assert.ok(changes.some((c) => c.path === 'x.txt'), `heard: ${JSON.stringify(changes)}`); + const heard = () => batches.flat().some((c) => c.path === 'x.txt'); + for (let i = 0; i < 400 && !heard(); i++) await new Promise((r) => setTimeout(r, 25)); + assert.ok(heard(), `heard: ${JSON.stringify(batches.flat().filter((c) => !isProbe(c.path)))}`); }); }); diff --git a/test/workspace-watcher.test.ts b/test/workspace-watcher.test.ts index 1a77899db..38fb5a383 100644 --- a/test/workspace-watcher.test.ts +++ b/test/workspace-watcher.test.ts @@ -3,9 +3,10 @@ import assert from 'node:assert'; import fs, { existsSync, mkdtempSync, mkdirSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, statSync, symlinkSync, writeFileSync, unlinkSync, rmSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { dirname, join } from 'node:path'; import { MountWatcher, type FsChange, type WatcherLifecycle } from '../src/modules/workspace/watcher.js'; +import { changesFlow, isProbe } from './helpers/watcher-flow.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; // Much shorter than the watcher's defaults (a 300 ms debounce, a 2 s root @@ -161,41 +162,6 @@ async function changeFor(batches: FsChange[][], path: string, op?: FsChange['op' ); } -// The probe files changesFlow writes, which a test's results leave out by name. -const PROBE_PREFIX = 'probe-'; -const isProbe = (path: string): boolean => basename(path).startsWith(PROBE_PREFIX); - -/** - * Waits until the watcher reports a change in each of `dirs`, relative to the - * mount's root `root` (the root itself when `dirs` names none), so what a test - * writes there next is written while changes are delivered. `onReady` says - * chokidar's first scan is done, not that the platform delivers events yet. By - * our reading of libuv, on macOS `fs.watch` of a directory returns before - * libuv's own thread has made the FSEventStream that reports it, a stream that - * starts "since now" and is made again for every watched directory each time - * 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). - */ -async function changesFlow(root: string, batches: FsChange[][], dirs: readonly string[]): Promise { - const where = dirs.length ? dirs : ['']; - const start = Date.now(); - const left = () => Date.now() - start < EVENT_MS; - const reportedIn = (dir: string) => batches.flat().some((c) => isProbe(c.path) && dirname(c.path) === (dir || '.')); - for (let n = 0; left(); n++) { - for (const dir of where) if (!reportedIn(dir)) writeFileSync(join(root, dir, `${PROBE_PREFIX}${n}.txt`), String(n)); - for (const attempt = Date.now(); Date.now() - attempt < 1_000 && left(); await wait(25)) { - if (where.every(reportedIn)) return; - } - } - const seen = [...new Set(batches.flat().map((c) => c.path))]; - assert.fail(`expected the watcher to report a probe in each of ${where.map((d) => d || '.').join(', ')}; it reported ${seen.join(', ') || 'nothing'}`); -} - /** * Hands the watcher chokidar's events for paths under the mount, as chokidar * reports them: chokidar's FSWatcher is an EventEmitter, and its own