a46764fb1b
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
264 lines
9.7 KiB
TypeScript
264 lines
9.7 KiB
TypeScript
// @ts-nocheck
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
_activeWatcherCount,
|
|
_resetForTests,
|
|
subscribe,
|
|
} from '../src/project-watchers.js';
|
|
|
|
function fakeFactory() {
|
|
return (dir, _opts) => ({
|
|
dir,
|
|
watcher: { close: async () => { factoryCloses++; } },
|
|
ready: Promise.resolve(),
|
|
subscribers: new Set(),
|
|
closing: null,
|
|
});
|
|
}
|
|
|
|
let factoryCloses = 0;
|
|
|
|
afterEach(async () => {
|
|
await _resetForTests();
|
|
factoryCloses = 0;
|
|
});
|
|
|
|
async function makeProjectsRoot() {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'od-watchers-'));
|
|
const projectId = 'proj-' + Math.random().toString(36).slice(2, 10);
|
|
await mkdir(path.join(root, projectId), { recursive: true });
|
|
return { root, projectId };
|
|
}
|
|
|
|
function waitFor(predicate, { timeout = 2000, interval = 25 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const started = Date.now();
|
|
const tick = () => {
|
|
try {
|
|
if (predicate()) return resolve(undefined);
|
|
} catch (err) {
|
|
return reject(err);
|
|
}
|
|
if (Date.now() - started > timeout) return reject(new Error('waitFor timeout'));
|
|
setTimeout(tick, interval);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
describe('project-watchers (refcounting)', () => {
|
|
it('lazy-creates a watcher on first subscribe and closes on last unsubscribe', async () => {
|
|
const { root, projectId } = await makeProjectsRoot();
|
|
const factory = fakeFactory();
|
|
|
|
expect(_activeWatcherCount()).toBe(0);
|
|
|
|
const sub1 = subscribe(root, projectId, () => {}, { _watcherFactory: factory });
|
|
expect(_activeWatcherCount()).toBe(1);
|
|
|
|
const sub2 = subscribe(root, projectId, () => {}, { _watcherFactory: factory });
|
|
expect(_activeWatcherCount()).toBe(1); // still one
|
|
|
|
await sub1.unsubscribe();
|
|
expect(_activeWatcherCount()).toBe(1); // not yet — second sub still alive
|
|
expect(factoryCloses).toBe(0);
|
|
|
|
await sub2.unsubscribe();
|
|
expect(_activeWatcherCount()).toBe(0);
|
|
expect(factoryCloses).toBe(1);
|
|
});
|
|
|
|
it('separate projects get separate watchers', async () => {
|
|
const { root, projectId: a } = await makeProjectsRoot();
|
|
const { projectId: b } = await makeProjectsRoot();
|
|
await mkdir(path.join(root, b), { recursive: true });
|
|
const factory = fakeFactory();
|
|
|
|
const sub1 = subscribe(root, a, () => {}, { _watcherFactory: factory });
|
|
const sub2 = subscribe(root, b, () => {}, { _watcherFactory: factory });
|
|
expect(_activeWatcherCount()).toBe(2);
|
|
|
|
await sub1.unsubscribe();
|
|
await sub2.unsubscribe();
|
|
expect(_activeWatcherCount()).toBe(0);
|
|
expect(factoryCloses).toBe(2);
|
|
});
|
|
|
|
it('idempotent unsubscribe', async () => {
|
|
const { root, projectId } = await makeProjectsRoot();
|
|
const { unsubscribe } = subscribe(root, projectId, () => {}, { _watcherFactory: fakeFactory() });
|
|
await unsubscribe();
|
|
await unsubscribe();
|
|
expect(_activeWatcherCount()).toBe(0);
|
|
expect(factoryCloses).toBe(1);
|
|
});
|
|
|
|
it('rejects an invalid project id', () => {
|
|
expect(() =>
|
|
subscribe('/tmp', '../escape', () => {}, { _watcherFactory: fakeFactory() }),
|
|
).toThrow(/invalid project id/);
|
|
});
|
|
});
|
|
|
|
describe('project-watchers (real chokidar)', () => {
|
|
it('emits file-changed events on add / change / unlink', async () => {
|
|
const { root, projectId } = await makeProjectsRoot();
|
|
const events = [];
|
|
const sub = subscribe(root, projectId, (e) => events.push(e));
|
|
await sub.ready;
|
|
|
|
try {
|
|
const filePath = path.join(root, projectId, 'hello.txt');
|
|
await writeFile(filePath, 'first');
|
|
await waitFor(() => events.some((e) => e.kind === 'add' && e.path === 'hello.txt'));
|
|
|
|
await writeFile(filePath, 'second');
|
|
await waitFor(() => events.some((e) => e.kind === 'change' && e.path === 'hello.txt'));
|
|
|
|
await rm(filePath);
|
|
await waitFor(() => events.some((e) => e.kind === 'unlink' && e.path === 'hello.txt'));
|
|
|
|
expect(events.every((e) => e.type === 'file-changed')).toBe(true);
|
|
} finally {
|
|
await sub.unsubscribe();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 8_000);
|
|
|
|
it('still emits events when the watch root is itself nested under .od/ (production layout)', async () => {
|
|
// Reproduces the layout the daemon actually uses:
|
|
// <RUNTIME_DATA_DIR>/.od/projects/<id>/...
|
|
// The ignore predicate must not match the watch root's ancestor directories,
|
|
// only segments inside the watched tree.
|
|
const dataRoot = await mkdtemp(path.join(tmpdir(), 'od-data-'));
|
|
const projectsRoot = path.join(dataRoot, '.od', 'projects');
|
|
const projectId = 'proj-' + Math.random().toString(36).slice(2, 10);
|
|
await mkdir(path.join(projectsRoot, projectId, 'prototype'), { recursive: true });
|
|
|
|
const events = [];
|
|
const sub = subscribe(projectsRoot, projectId, (e) => events.push(e));
|
|
await sub.ready;
|
|
|
|
try {
|
|
const filePath = path.join(projectsRoot, projectId, 'prototype', 'App.jsx');
|
|
await writeFile(filePath, 'export default () => null;');
|
|
await waitFor(
|
|
() => events.some((e) => e.kind === 'add' && e.path === 'prototype/App.jsx'),
|
|
{ timeout: 4000 },
|
|
);
|
|
} finally {
|
|
await sub.unsubscribe();
|
|
await rm(dataRoot, { recursive: true, force: true });
|
|
}
|
|
}, 8_000);
|
|
|
|
it('ignores files inside .od/ and node_modules/', async () => {
|
|
const { root, projectId } = await makeProjectsRoot();
|
|
const events = [];
|
|
const sub = subscribe(root, projectId, (e) => events.push(e));
|
|
await sub.ready;
|
|
|
|
try {
|
|
await mkdir(path.join(root, projectId, '.od'), { recursive: true });
|
|
await writeFile(path.join(root, projectId, '.od', 'state.json'), '{}');
|
|
await mkdir(path.join(root, projectId, 'node_modules'), { recursive: true });
|
|
await writeFile(path.join(root, projectId, 'node_modules', 'x.js'), '');
|
|
|
|
await writeFile(path.join(root, projectId, 'real.txt'), 'real');
|
|
await waitFor(() => events.some((e) => e.path === 'real.txt'));
|
|
|
|
const ignored = events.filter(
|
|
(e) => e.path.startsWith('.od/') || e.path.startsWith('node_modules/'),
|
|
);
|
|
expect(ignored).toEqual([]);
|
|
} finally {
|
|
await sub.unsubscribe();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 8_000);
|
|
|
|
it('attaches an error listener and survives an emitted error event', async () => {
|
|
// Regression for codex P1: chokidar's FSWatcher is an EventEmitter.
|
|
// Without an 'error' listener, transient FS faults (ENOSPC, EPERM,
|
|
// EMFILE on saturated inotify watches) would surface as unhandled
|
|
// exceptions and could crash the daemon — taking down all routes.
|
|
const { _internalWatcherForTests } = await import('../src/project-watchers.js');
|
|
const { root, projectId } = await makeProjectsRoot();
|
|
const events = [];
|
|
const sub = subscribe(root, projectId, (e) => events.push(e));
|
|
await sub.ready;
|
|
|
|
try {
|
|
const watcher = _internalWatcherForTests(root, projectId);
|
|
expect(watcher).toBeDefined();
|
|
// The listener must be registered — listenerCount > 0 proves it.
|
|
expect(watcher.listenerCount('error')).toBeGreaterThan(0);
|
|
|
|
// Behavioural: emitting an error must not throw or crash the process,
|
|
// and subsequent file events must still arrive on the same watcher.
|
|
expect(() => watcher.emit('error', new Error('synthetic ENOSPC'))).not.toThrow();
|
|
const filePath = path.join(root, projectId, 'after-error.txt');
|
|
await writeFile(filePath, 'still alive');
|
|
await waitFor(() => events.some((e) => e.path === 'after-error.txt'));
|
|
} finally {
|
|
await sub.unsubscribe();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 8_000);
|
|
});
|
|
|
|
describe('project-watchers (chokidar options)', () => {
|
|
it('does not follow symlinks out of the watch root (production factory)', async () => {
|
|
// Real chokidar test: create a symlink inside the project pointing to a
|
|
// sibling directory outside the project. Writing to the external sibling
|
|
// must NOT produce an event scoped to the symlink path, because
|
|
// followSymlinks is false.
|
|
const dataRoot = await mkdtemp(path.join(tmpdir(), 'od-symlink-'));
|
|
const { symlink } = await import('node:fs/promises');
|
|
const projectId = 'proj-' + Math.random().toString(36).slice(2, 10);
|
|
const projectRoot = path.join(dataRoot, projectId);
|
|
await mkdir(projectRoot, { recursive: true });
|
|
const externalDir = path.join(dataRoot, 'external');
|
|
await mkdir(externalDir, { recursive: true });
|
|
try {
|
|
await symlink(externalDir, path.join(projectRoot, 'linked'), 'dir');
|
|
} catch (err) {
|
|
// Some filesystems disallow symlinks. Skip without failing the suite.
|
|
if (
|
|
err &&
|
|
typeof err === 'object' &&
|
|
'code' in err &&
|
|
(err.code === 'EPERM' || err.code === 'ENOTSUP')
|
|
) {
|
|
await rm(dataRoot, { recursive: true, force: true });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const events = [];
|
|
const sub = subscribe(dataRoot, projectId, (e) => events.push(e));
|
|
await sub.ready;
|
|
|
|
try {
|
|
// Write to a file via the external path. With followSymlinks: false,
|
|
// chokidar isn't traversing the symlink, so no event with a "linked/"
|
|
// prefix should arrive.
|
|
await writeFile(path.join(externalDir, 'leaked.txt'), 'leak');
|
|
// Settle: write a real in-project file to give chokidar something to do.
|
|
await writeFile(path.join(projectRoot, 'real.txt'), 'real');
|
|
await waitFor(() => events.some((e) => e.path === 'real.txt'));
|
|
|
|
const linkedEvents = events.filter((e) => e.path.startsWith('linked/'));
|
|
expect(linkedEvents).toEqual([]);
|
|
} finally {
|
|
await sub.unsubscribe();
|
|
await rm(dataRoot, { recursive: true, force: true });
|
|
}
|
|
}, 8_000);
|
|
});
|