@c9up/helix 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/runtime/suite.d.ts +26 -0
  2. package/dist/runtime/suite.d.ts.map +1 -1
  3. package/dist/runtime/suite.js +21 -18
  4. package/dist/runtime/suite.js.map +1 -1
  5. package/index.darwin-arm64.node +0 -0
  6. package/index.darwin-x64.node +0 -0
  7. package/index.linux-arm64-gnu.node +0 -0
  8. package/index.linux-x64-gnu.node +0 -0
  9. package/index.win32-x64-msvc.node +0 -0
  10. package/package.json +2 -2
  11. package/src/cli/coverage/aggregate.ts +0 -231
  12. package/src/cli/coverage/collect.ts +0 -63
  13. package/src/cli/coverage/diff/base.ts +0 -46
  14. package/src/cli/coverage/diff/index.ts +0 -160
  15. package/src/cli/coverage/diff/overlay.ts +0 -62
  16. package/src/cli/coverage/diff/parse.ts +0 -121
  17. package/src/cli/coverage/diff/reporters.ts +0 -82
  18. package/src/cli/coverage/diff/types.ts +0 -46
  19. package/src/cli/coverage/filter.ts +0 -71
  20. package/src/cli/coverage/glob.ts +0 -0
  21. package/src/cli/coverage/index.ts +0 -126
  22. package/src/cli/coverage/reporters/json.ts +0 -40
  23. package/src/cli/coverage/reporters/lcov.ts +0 -54
  24. package/src/cli/coverage/reporters/text.ts +0 -48
  25. package/src/cli/coverage/thresholds.ts +0 -73
  26. package/src/cli/coverage/types.ts +0 -93
  27. package/src/cli/discover.ts +0 -174
  28. package/src/cli/native.ts +0 -104
  29. package/src/cli/pool.ts +0 -486
  30. package/src/cli/reporter.ts +0 -155
  31. package/src/cli/run.ts +0 -440
  32. package/src/cli/summary.ts +0 -42
  33. package/src/cli/watch/loop.ts +0 -159
  34. package/src/cli/watch/types.ts +0 -22
  35. package/src/cli/watch/watcher.ts +0 -145
  36. package/src/container/index.ts +0 -16
  37. package/src/container/override.ts +0 -86
  38. package/src/container/spy.ts +0 -25
  39. package/src/index.ts +0 -42
  40. package/src/runtime/assertion-error.ts +0 -38
  41. package/src/runtime/cli-worker.ts +0 -140
  42. package/src/runtime/equals.ts +0 -400
  43. package/src/runtime/expect.ts +0 -173
  44. package/src/runtime/index.ts +0 -50
  45. package/src/runtime/lifecycle.ts +0 -17
  46. package/src/runtime/matchers.ts +0 -452
  47. package/src/runtime/run.ts +0 -573
  48. package/src/runtime/suite.ts +0 -310
  49. package/src/runtime/test-context.ts +0 -59
  50. package/src/runtime/vi/fake-timers.ts +0 -410
  51. package/src/runtime/vi/index.ts +0 -254
  52. package/src/runtime/vi/spy.ts +0 -224
  53. package/src/runtime/vi/spyOn.ts +0 -155
  54. package/src/runtime/vi/system-time.ts +0 -121
  55. package/src/runtime/worker.ts +0 -239
  56. package/src/time/freeze.ts +0 -229
  57. package/src/time/index.ts +0 -16
@@ -1,145 +0,0 @@
1
- /**
2
- * Recursive file watcher for `helix test --watch`. Wraps Node's
3
- * `fs.watch(root, { recursive: true })` with a glob filter (reusing the
4
- * coverage glob translator) and a single-timer debounce that coalesces
5
- * burst events into one `onChange` call.
6
- *
7
- * The shape (`createWatcher` returning a `{ close }` handle) is the
8
- * abstraction a future Rust `notify`-NAPI implementation slots into
9
- * without touching the loop.
10
- */
11
-
12
- import { type FSWatcher, watch as fsWatch, realpathSync } from "node:fs";
13
- import path from "node:path";
14
- import { compileGlobs, matchesAnyGlob } from "../coverage/glob.js";
15
-
16
- export interface CreateWatcherOptions {
17
- root: string;
18
- include: string[];
19
- exclude: string[];
20
- debounceMs: number;
21
- onChange: (paths: Set<string>) => void;
22
- /** Surfaces `FSWatcher` errors (recursive unsupported, EMFILE, etc.)
23
- * so the loop can stop instead of waiting forever on a dead handle. */
24
- onError?: (err: Error) => void;
25
- }
26
-
27
- export interface WatcherHandle {
28
- close(): Promise<void>;
29
- }
30
-
31
- const DEBOUNCE_MIN = 1;
32
- const DEBOUNCE_MAX = 5_000;
33
-
34
- function tryRealpath(p: string): string {
35
- try {
36
- return realpathSync(p);
37
- } catch {
38
- return p;
39
- }
40
- }
41
-
42
- export function createWatcher(opts: CreateWatcherOptions): WatcherHandle {
43
- if (
44
- opts.debounceMs < DEBOUNCE_MIN ||
45
- opts.debounceMs > DEBOUNCE_MAX ||
46
- !Number.isFinite(opts.debounceMs)
47
- ) {
48
- throw new Error(
49
- `createWatcher: debounceMs must be in [${DEBOUNCE_MIN}, ${DEBOUNCE_MAX}], got ${opts.debounceMs}`,
50
- );
51
- }
52
- if (opts.include.length === 0) {
53
- throw new Error(
54
- "createWatcher: include must be non-empty (an empty include matches nothing — silent watcher).",
55
- );
56
- }
57
-
58
- const include = compileGlobs(opts.include);
59
- const exclude = compileGlobs(opts.exclude);
60
- // Realpath the root so events delivered with the canonical path
61
- // (macOS `/tmp` → `/private/tmp`, Docker bind-mounts, pnpm worktree
62
- // symlinks) don't get filtered out as "outside root". Mirrors what
63
- // `coverage/filter.ts` does for the same reason.
64
- const root = tryRealpath(path.resolve(opts.root));
65
-
66
- const pending = new Set<string>();
67
- let timer: NodeJS.Timeout | undefined;
68
- let closed = false;
69
-
70
- function flush(): void {
71
- timer = undefined;
72
- if (closed) return;
73
- if (pending.size === 0) return;
74
- const batch = new Set(pending);
75
- pending.clear();
76
- opts.onChange(batch);
77
- }
78
-
79
- function arm(): void {
80
- if (timer) clearTimeout(timer);
81
- timer = setTimeout(flush, opts.debounceMs);
82
- }
83
-
84
- function shouldNotify(filename: string | null): string | undefined {
85
- if (!filename) return undefined;
86
- const abs = path.resolve(root, filename);
87
- const rel = path.relative(root, abs).split(path.sep).join("/");
88
- if (rel.length === 0 || rel.startsWith("..") || path.isAbsolute(rel)) {
89
- return undefined;
90
- }
91
- if (matchesAnyGlob(exclude, rel)) return undefined;
92
- if (!matchesAnyGlob(include, rel)) return undefined;
93
- return abs;
94
- }
95
-
96
- let watcher: FSWatcher;
97
- try {
98
- watcher = fsWatch(root, { recursive: true }, (_event, filename) => {
99
- if (closed) return;
100
- const abs = shouldNotify(typeof filename === "string" ? filename : null);
101
- if (!abs) return;
102
- pending.add(abs);
103
- arm();
104
- });
105
- } catch (err) {
106
- throw new Error(
107
- `createWatcher: fs.watch failed on ${root}: ${
108
- err instanceof Error ? err.message : String(err)
109
- }`,
110
- );
111
- }
112
-
113
- // On `'error'`, surface the failure to the caller and shut down so
114
- // the loop doesn't sit on a dead handle. Without this, recursive
115
- // watch failures on Linux<20 leave the user staring at a "watching…"
116
- // banner that will never tick.
117
- watcher.on("error", (err) => {
118
- if (closed) return;
119
- closed = true;
120
- if (timer) {
121
- clearTimeout(timer);
122
- timer = undefined;
123
- }
124
- try {
125
- watcher.close();
126
- } catch {
127
- /* already torn down */
128
- }
129
- opts.onError?.(err instanceof Error ? err : new Error(String(err)));
130
- });
131
-
132
- return {
133
- async close(): Promise<void> {
134
- if (closed) return;
135
- closed = true;
136
- if (timer) {
137
- clearTimeout(timer);
138
- timer = undefined;
139
- }
140
- pending.clear();
141
- watcher.close();
142
- await new Promise<void>((resolve) => setImmediate(resolve));
143
- },
144
- };
145
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * `@c9up/helix/container` — barrel for the test-container facade.
3
- */
4
-
5
- export type {
6
- ContainerLike,
7
- ContainerToken,
8
- } from "../runtime/vi/index.js";
9
- export {
10
- clearActiveContainer,
11
- type HelixContainer,
12
- override,
13
- overrideOn,
14
- useContainer,
15
- } from "./override.js";
16
- export { spy } from "./spy.js";
@@ -1,86 +0,0 @@
1
- /**
2
- * Helix facade over `@c9up/ream`'s `Container.override(token, value)`.
3
- *
4
- * Adds two things to the raw container call:
5
- * 1. The override is registered against the active test context's
6
- * auto-restore queue, so it's undone after the test even if the
7
- * test body never calls `restore()` itself.
8
- * 2. A clear error when called outside a test (no AsyncLocalStorage
9
- * frame) — the queued cleanup would never fire there.
10
- *
11
- * The active container is passed via `useContainer(container)` (called
12
- * by the host app's test bootstrap). For bare unit tests with no host
13
- * app, callers can pass the container explicitly to `overrideOn`.
14
- */
15
-
16
- import { inTestContext, registerTestCleanup } from "../runtime/test-context.js";
17
- import type { ContainerLike, ContainerToken } from "../runtime/vi/index.js";
18
-
19
- /** Container surface helix needs: a value-based override + a restore. */
20
- export interface HelixContainer extends ContainerLike {
21
- override(token: ContainerToken, value: unknown): void;
22
- }
23
-
24
- let activeContainer: HelixContainer | undefined;
25
-
26
- /**
27
- * Bind a container instance as active for `helix.override(...)` calls.
28
- *
29
- * If called inside a test frame, the previous active container is
30
- * captured and restored when the frame ends — so swapping the active
31
- * container mid-test does not leak into the next test. Outside a test
32
- * frame the assignment is permanent until the next `useContainer` /
33
- * `clearActiveContainer` call.
34
- */
35
- export function useContainer(container: HelixContainer): void {
36
- const previous = activeContainer;
37
- activeContainer = container;
38
- registerTestCleanup(() => {
39
- activeContainer = previous;
40
- });
41
- }
42
-
43
- /** Reset the active container binding. Typically not needed in user
44
- * code — `useContainer` already auto-restores when called inside a
45
- * test. Exposed so `afterEach` blocks (and unit tests) can clear the
46
- * module-scoped slot explicitly. */
47
- export function clearActiveContainer(): void {
48
- activeContainer = undefined;
49
- }
50
-
51
- /**
52
- * Override a binding on the active container with a value, and queue
53
- * the restore for end-of-test. Use `overrideOn` when you need to
54
- * target a specific container instance.
55
- *
56
- * Throws if called outside a test frame: a queued cleanup would never
57
- * fire there, so the override would leak across tests silently.
58
- */
59
- export function override(token: ContainerToken, value: unknown): void {
60
- if (!inTestContext()) {
61
- throw new Error(
62
- "helix.override: must be called inside a test (no active test frame). Calls from top-level setup leak across tests — use container.override() directly with manual restore() if that is what you want.",
63
- );
64
- }
65
- if (!activeContainer) {
66
- throw new Error(
67
- "helix.override: no active container. Call helix.useContainer(container) before invoking override(), or use overrideOn(container, token, value).",
68
- );
69
- }
70
- overrideOn(activeContainer, token, value);
71
- }
72
-
73
- /** Override on a specific container instance. */
74
- export function overrideOn(
75
- container: HelixContainer,
76
- token: ContainerToken,
77
- value: unknown,
78
- ): void {
79
- container.override(token, value);
80
- // `registerTestCleanup` returns false when called outside a test
81
- // frame (e.g. raw vitest tests, REPL, top-level setup). In that
82
- // case the caller is responsible for `container.restore(token)` —
83
- // we don't queue a fallback that might fire in an unexpected
84
- // frame.
85
- registerTestCleanup(() => container.restore(token));
86
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * `helix.spy()` — Jest-like spy factory.
3
- *
4
- * Thin alias over `vi.fn()` so the documented one-liner from FR69 /
5
- * Story 42.3 reads:
6
- *
7
- * const fakeMail = { send: spy() };
8
- * override('mail', fakeMail);
9
- * // ...
10
- * expect(fakeMail.send).toHaveBeenCalledOnce();
11
- */
12
-
13
- import { vi } from "../runtime/vi/index.js";
14
- import type { AnyFn, Spy } from "../runtime/vi/spy.js";
15
-
16
- /**
17
- * Wrap (don't bind) `vi.fn` so `spy` always reaches through to the
18
- * current `vi.fn` even if it gets replaced at runtime — and so the
19
- * captured reference can never go stale relative to the live `vi`
20
- * object. The cost over `export const spy = vi.fn` is one extra
21
- * function call per spawn.
22
- */
23
- export function spy<Fn extends AnyFn>(implementation?: Fn): Spy<Fn> {
24
- return vi.fn(implementation);
25
- }
package/src/index.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * `@c9up/helix` — the framework-agnostic test runtime for the Ream ecosystem.
3
- *
4
- * This barrel exposes the Vitest-compatible runtime (describe/test/expect/vi/
5
- * spies/lifecycle), the container facade, and time-travel — none of which import
6
- * an ecosystem package. It is fully agnostic: usable in any project.
7
- *
8
- * Per-package test fakes/helpers live in EACH package's own `/testing` subpath
9
- * (a package owns its test surface, not helix): `@c9up/ream/testing`
10
- * (TestClient, FakeBus, assertEmitted), `@c9up/atlas/testing` (factory,
11
- * useTransaction), `@c9up/rover/testing` (FakeMail), `@c9up/bay/testing`
12
- * (FakeQueue), `@c9up/spectrum/testing` (FakeLogger), `@c9up/nova/testing`
13
- * (FakeNova), `@c9up/relay/testing` (FakeRelay), `@c9up/archive/testing`
14
- * (FakeStorage). Install a package → you get its testing surface.
15
- */
16
-
17
- export * from "./container/index.js";
18
- export type {
19
- Assertion,
20
- FileResult,
21
- Hook,
22
- HookType,
23
- MatcherName,
24
- MatcherResult,
25
- Spy,
26
- SuiteResult,
27
- TestResult,
28
- Vi,
29
- } from "./runtime/index.js";
30
- export {
31
- AssertionError,
32
- afterAll,
33
- afterEach,
34
- beforeAll,
35
- beforeEach,
36
- describe,
37
- expect,
38
- it,
39
- test,
40
- vi,
41
- } from "./runtime/index.js";
42
- export * as time from "./time/index.js";
@@ -1,38 +0,0 @@
1
- /**
2
- * Assertion error thrown by `expect` — carries structured fields so runners
3
- * (Vitest-compatible reporters, IDE integrations) can render diffs.
4
- */
5
- export interface AssertionErrorInit {
6
- message: string;
7
- actual?: unknown;
8
- expected?: unknown;
9
- operator?: string;
10
- showDiff?: boolean;
11
- }
12
-
13
- export class AssertionError extends Error {
14
- readonly actual: unknown;
15
- readonly expected: unknown;
16
- readonly operator: string | undefined;
17
- readonly showDiff: boolean;
18
-
19
- constructor(init: AssertionErrorInit) {
20
- super(init.message);
21
- this.name = "AssertionError";
22
- this.actual = init.actual;
23
- this.expected = init.expected;
24
- this.operator = init.operator;
25
- this.showDiff = init.showDiff ?? true;
26
- // V8 only: strip framework frames so the stack points at user code.
27
- const capture = (
28
- Error as { captureStackTrace?: (target: object, ctor: object) => void }
29
- ).captureStackTrace;
30
- if (typeof capture === "function") {
31
- capture(this, AssertionError);
32
- }
33
- }
34
- }
35
-
36
- export function isAssertionError(value: unknown): value is AssertionError {
37
- return value instanceof AssertionError;
38
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * CLI worker entry — what the orchestrator spawns as a child process.
3
- *
4
- * Protocol:
5
- * 1. Parent writes one `{ "type":"run", "file":"...", "timeoutMs":n, "nonce":"..." }`
6
- * line to stdin, then closes stdin.
7
- * 2. We run the file via `runTestFile`.
8
- * 3. We emit a single framed line on **stderr** prefixed with
9
- * `__HELIX_RESULT__`, with the parent's `nonce` echoed back so a
10
- * test that happens to print that prefix can't spoof the result.
11
- * 4. Exit with code 0 on `result`, 1 on `error`.
12
- *
13
- * Pre-handshake errors (e.g. malformed instruction on stdin) are emitted
14
- * with the `__helix_pre_handshake__` magic nonce — the parent accepts
15
- * those only for `type === "error"` so a fixture can't use the magic to
16
- * fake success.
17
- *
18
- * Flush discipline: on the happy path we await the stream drain callback
19
- * before setting `process.exitCode` so the frame always reaches the
20
- * parent. For the fatal `unhandledRejection` path we use `fs.writeSync`
21
- * on fd 2 to guarantee the bytes hit the pipe before Node tears the
22
- * process down.
23
- */
24
-
25
- import { writeSync } from "node:fs";
26
- import { createInterface } from "node:readline";
27
- import { runTestFile } from "./worker.js";
28
-
29
- const FRAME_PREFIX = "__HELIX_RESULT__";
30
- const PRE_HANDSHAKE_NONCE = "__helix_pre_handshake__";
31
-
32
- interface RunMessage {
33
- type: "run";
34
- file: string;
35
- timeoutMs?: number;
36
- nonce: string;
37
- }
38
-
39
- async function emit(msg: unknown): Promise<void> {
40
- const line = `${FRAME_PREFIX}${JSON.stringify(msg)}\n`;
41
- await new Promise<void>((resolve, reject) => {
42
- process.stderr.write(line, (err) => {
43
- if (err) reject(err);
44
- else resolve();
45
- });
46
- });
47
- }
48
-
49
- /**
50
- * Synchronous emit used from fatal handlers (unhandledRejection) where
51
- * we can't afford to lose the frame to the process tear-down racing the
52
- * pipe drain. `writeSync` blocks until the kernel accepts the bytes.
53
- */
54
- function emitSync(msg: unknown): void {
55
- try {
56
- writeSync(2, `${FRAME_PREFIX}${JSON.stringify(msg)}\n`);
57
- } catch {
58
- /* pipe broken — nothing we can do */
59
- }
60
- }
61
-
62
- async function readInstruction(): Promise<RunMessage> {
63
- const rl = createInterface({ input: process.stdin });
64
- for await (const line of rl) {
65
- const trimmed = line.trim();
66
- if (!trimmed) continue;
67
- // Parse inside try so a bad JSON line propagates as a typed error
68
- // rather than leaking a SyntaxError that escapes `for await`.
69
- let parsed: unknown;
70
- try {
71
- parsed = JSON.parse(trimmed);
72
- } catch (err) {
73
- throw new Error(
74
- `malformed instruction JSON: ${err instanceof Error ? err.message : String(err)}`,
75
- );
76
- }
77
- if (
78
- parsed &&
79
- typeof parsed === "object" &&
80
- (parsed as { type?: unknown }).type === "run" &&
81
- typeof (parsed as { file?: unknown }).file === "string" &&
82
- typeof (parsed as { nonce?: unknown }).nonce === "string"
83
- ) {
84
- return parsed as RunMessage;
85
- }
86
- throw new Error(`invalid instruction shape: ${trimmed}`);
87
- }
88
- throw new Error("stdin closed before instruction arrived");
89
- }
90
-
91
- async function main(): Promise<void> {
92
- let instr: RunMessage;
93
- try {
94
- instr = await readInstruction();
95
- } catch (err) {
96
- // We don't know the real nonce yet — emit with the pre-handshake
97
- // magic so the parent accepts this specific error path.
98
- await emit({
99
- type: "error",
100
- file: undefined,
101
- message: err instanceof Error ? err.message : String(err),
102
- stack: err instanceof Error ? err.stack : undefined,
103
- nonce: PRE_HANDSHAKE_NONCE,
104
- }).catch(() => {});
105
- process.exitCode = 1;
106
- return;
107
- }
108
-
109
- // Installed AFTER the instruction is read so we can echo the right nonce.
110
- // Synchronous write so Node's default "crash on unhandledRejection"
111
- // behaviour can't evict the frame before the parent reads it.
112
- process.on("unhandledRejection", (reason) => {
113
- emitSync({
114
- type: "error",
115
- file: instr.file,
116
- message: `unhandledRejection: ${reason instanceof Error ? reason.message : String(reason)}`,
117
- stack: reason instanceof Error ? reason.stack : undefined,
118
- nonce: instr.nonce,
119
- });
120
- });
121
-
122
- try {
123
- const result = await runTestFile(instr.file, {
124
- timeoutMs: instr.timeoutMs,
125
- });
126
- await emit({ type: "result", result, nonce: instr.nonce });
127
- process.exitCode = result.totals.fail > 0 ? 1 : 0;
128
- } catch (err) {
129
- await emit({
130
- type: "error",
131
- file: instr.file,
132
- message: err instanceof Error ? err.message : String(err),
133
- stack: err instanceof Error ? err.stack : undefined,
134
- nonce: instr.nonce,
135
- }).catch(() => {});
136
- process.exitCode = 1;
137
- }
138
- }
139
-
140
- void main();