Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions test/helpers/watcher-flow.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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'}`);
}
13 changes: 9 additions & 4 deletions test/workspace-nested-mount-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)))}`);
});
});
38 changes: 2 additions & 36 deletions test/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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
Expand Down
Loading