2026-03-01 14:33:18 +01:00
|
|
|
/**
|
|
|
|
|
* Reusable async test helpers.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-30 21:43:45 -05:00
|
|
|
import { rmSync } from 'node:fs';
|
2026-10-12 16:58:16 +08:00
|
|
|
import { basename, resolve } from 'node:path';
|
2026-08-30 21:43:45 -05:00
|
|
|
|
2026-03-01 14:33:18 +01:00
|
|
|
/** Wait for an EventEmitter to emit a specific event, with timeout */
|
|
|
|
|
export function waitForEvent(
|
|
|
|
|
emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void },
|
|
|
|
|
event: string,
|
2026-04-23 11:56:22 +02:00
|
|
|
timeoutMs = 5000
|
2026-03-01 14:33:18 +01:00
|
|
|
): Promise<unknown> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const timer = setTimeout(
|
|
|
|
|
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
|
2026-04-23 11:56:22 +02:00
|
|
|
timeoutMs
|
2026-03-01 14:33:18 +01:00
|
|
|
);
|
|
|
|
|
emitter.once(event, (...args: unknown[]) => {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
resolve(args.length === 1 ? args[0] : args);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Create a deferred promise with external resolve/reject */
|
|
|
|
|
export function createDeferred<T = void>(): {
|
|
|
|
|
promise: Promise<T>;
|
|
|
|
|
resolve: (value: T) => void;
|
|
|
|
|
reject: (reason?: unknown) => void;
|
|
|
|
|
} {
|
|
|
|
|
let resolve!: (value: T) => void;
|
|
|
|
|
let reject!: (reason?: unknown) => void;
|
|
|
|
|
const promise = new Promise<T>((res, rej) => {
|
|
|
|
|
resolve = res;
|
|
|
|
|
reject = rej;
|
|
|
|
|
});
|
|
|
|
|
return { promise, resolve, reject };
|
|
|
|
|
}
|
2026-08-30 21:43:45 -05:00
|
|
|
|
|
|
|
|
/**
|
2026-10-12 16:58:16 +08:00
|
|
|
* Prefix of the throwaway HOME `test/setup.ts` creates (`mkdtempSync(join(tmpdir(), 'codeman-vitest-'))`).
|
|
|
|
|
* Pinned by test/test-env-isolation.test.ts, so renaming one without the other fails there.
|
2026-08-30 21:43:45 -05:00
|
|
|
*/
|
2026-10-12 16:58:16 +08:00
|
|
|
export const TEST_HOME_PREFIX = 'codeman-vitest-';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True only when `process.env.HOME` is the throwaway directory `test/setup.ts` made.
|
|
|
|
|
*
|
|
|
|
|
* ⚠️ This is what the helpers below hang on, and it exists because "is the path under HOME"
|
|
|
|
|
* is a vacuous question when setup never ran: a bare `vitest` invocation has no setupFiles,
|
|
|
|
|
* HOME is the developer's REAL home, and every real file is then "under the test HOME". That is
|
|
|
|
|
* how a bare run on 2026-10-10 deleted the real linked-cases registry through a guard that was
|
|
|
|
|
* written to prevent exactly that. The temp directory's name is the only thing a real home
|
|
|
|
|
* cannot have.
|
|
|
|
|
*/
|
|
|
|
|
export function isSandboxedHome(): boolean {
|
2026-08-30 21:43:45 -05:00
|
|
|
const home = process.env.HOME;
|
2026-10-12 16:58:16 +08:00
|
|
|
return !!home && basename(resolve(home)).startsWith(TEST_HOME_PREFIX);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness
|
2026-10-22 19:25:52 +02:00
|
|
|
* (`createRouteTestHarness`) calls this, so a route test built on it refuses to start against a real
|
|
|
|
|
* `~/.codeman`. A route test that builds its own app from `createMockRouteContext` is not covered.
|
2026-10-12 16:58:16 +08:00
|
|
|
*/
|
|
|
|
|
export function assertSandboxedHome(what = 'this test'): void {
|
|
|
|
|
if (!isSandboxedHome()) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${what} reads and writes Codeman's data files and must run under test/setup.ts's temp HOME ` +
|
|
|
|
|
`(HOME=${process.env.HOME ?? '(unset)'}). Run it with \`npm test\` or ` +
|
|
|
|
|
`\`npx vitest run --config config/vitest.ci.config.ts <file>\`, never with a bare vitest.`
|
|
|
|
|
);
|
2026-08-30 21:43:45 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-12 16:58:16 +08:00
|
|
|
* Delete a directory tree, but ONLY when it lives inside the test HOME.
|
|
|
|
|
*
|
|
|
|
|
* SAFETY (2026-08-29, tightened 2026-10-12): `test/setup.ts` redirects `process.env.HOME` to a
|
|
|
|
|
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, recursive)` normally
|
|
|
|
|
* lands inside the fixture. This gate is defense in depth for the day that stops being true
|
|
|
|
|
* (a test that runs outside setup.ts, an env override that anchors a path elsewhere): it
|
|
|
|
|
* refuses to delete anything unless HOME IS the throwaway dir ({@link isSandboxedHome}) and the
|
|
|
|
|
* target is under it, so the failure mode is a leftover temp dir rather than a deleted
|
|
|
|
|
* PRODUCTION `~/codeman-cases`. The first version only checked "under HOME", which a run
|
|
|
|
|
* without setup.ts satisfied for the user's real home.
|
|
|
|
|
* Lexical `resolve()` is used because the leaf often does not exist and
|
|
|
|
|
* `realpathSync` would throw.
|
|
|
|
|
*/
|
|
|
|
|
export function safeRmHomeTree(path: string): void {
|
|
|
|
|
if (!isUnderTestHome(path)) return;
|
|
|
|
|
rmSync(resolve(path), { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True when `path` resolves strictly inside the throwaway test HOME (or to it), and false
|
|
|
|
|
* whenever HOME is anything else. Same rationale as `safeRmHomeTree`; use for guarded
|
|
|
|
|
* non-recursive deletes (single files like `linked-cases.json`) so they can never touch
|
|
|
|
|
* prod state.
|
2026-08-30 21:43:45 -05:00
|
|
|
*/
|
|
|
|
|
export function isUnderTestHome(path: string): boolean {
|
2026-10-12 16:58:16 +08:00
|
|
|
if (!isSandboxedHome()) return false;
|
|
|
|
|
const root = resolve(process.env.HOME as string);
|
2026-08-30 21:43:45 -05:00
|
|
|
const target = resolve(path);
|
|
|
|
|
return target === root || target.startsWith(root + '/');
|
|
|
|
|
}
|