@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
package/src/cli/run.ts DELETED
@@ -1,440 +0,0 @@
1
- /**
2
- * Orchestrator entry point — the TS equivalent of `ream-test-napi::run`.
3
- *
4
- * Discovers test files (or uses the explicit list), spawns the worker
5
- * pool, collects framed results, aggregates into a `Summary`, and invokes
6
- * the reporter at each milestone. When coverage is enabled, also threads
7
- * the V8 coverage lifecycle (open session → forward `NODE_V8_COVERAGE`
8
- * env to workers → aggregate after pool drain → write reports → enforce
9
- * thresholds).
10
- *
11
- * Designed so the future NAPI binding can be plugged in as a drop-in
12
- * replacement: both sides accept the same `RunConfig` shape and return
13
- * the same `Summary`.
14
- */
15
-
16
- import { existsSync } from "node:fs";
17
- import { rm } from "node:fs/promises";
18
- import os from "node:os";
19
- import path from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- import {
22
- type DiffOptions,
23
- type DiffSummary,
24
- diffViolationSummary,
25
- finaliseDiff,
26
- } from "./coverage/diff/index.js";
27
- import {
28
- type CoverageOptions,
29
- type CoverageSession,
30
- type CoverageSummary,
31
- finalise as finaliseCoverage,
32
- openSession,
33
- type ThresholdViolation,
34
- violationSummary,
35
- } from "./coverage/index.js";
36
- import { type DiscoveryOptions, discover } from "./discover.js";
37
- import { getNative } from "./native.js";
38
- import { runPool } from "./pool.js";
39
- import { makeReporter, type Reporter } from "./reporter.js";
40
- import {
41
- buildSummary,
42
- exitCode,
43
- type Summary,
44
- type Totals,
45
- } from "./summary.js";
46
- import { runWatch } from "./watch/loop.js";
47
- import type { WatchOptions } from "./watch/types.js";
48
-
49
- export type { WatchOptions } from "./watch/types.js";
50
-
51
- const DEFAULT_WATCH_DEBOUNCE_MS = 200;
52
- const DEFAULT_WATCH_INCLUDE = [
53
- "src/**/*.{ts,tsx,js,mjs,cjs}",
54
- "tests/**/*.{ts,tsx,js,mjs,cjs}",
55
- "test/**/*.{ts,tsx,js,mjs,cjs}",
56
- ];
57
- const DEFAULT_WATCH_EXCLUDE = [
58
- "node_modules/**",
59
- "dist/**",
60
- "build/**",
61
- "coverage/**",
62
- ".helix-coverage/**",
63
- ".git/**",
64
- ".wolf/**",
65
- "target/**",
66
- ".next/**",
67
- ];
68
-
69
- export interface RunConfig {
70
- /** Absolute root directory to discover from. */
71
- root: string;
72
- /** Explicit files to run. When non-empty, discovery is skipped. */
73
- files?: string[];
74
- /** Discovery options (suffixes, excludes). */
75
- discovery?: DiscoveryOptions;
76
- /** Number of concurrent workers. Default: os.cpus().length. */
77
- threads?: number;
78
- /** Per-file timeout (ms). Default 60 000. */
79
- timeoutMs?: number;
80
- /** Reporter name: `"dot" | "spec" | "json"`. Default `"spec"`. */
81
- reporter?: string;
82
- /** Enable ANSI colours. Default: stdout is TTY. */
83
- useColors?: boolean;
84
- /** Pluggable reporter instance (overrides `reporter` when provided). */
85
- reporterInstance?: Reporter;
86
- /** Override the node binary. Default: `process.execPath`. */
87
- nodeBin?: string;
88
- /** Extra args for node (before the worker entry). */
89
- nodeArgs?: string[];
90
- /** Override the worker entry path (defaults to bundled `cli-worker.ts`). */
91
- workerEntry?: string;
92
- /** Coverage collection + reporting. Defaults to `{ enabled: false }`. */
93
- coverage?: CoverageOptions;
94
- /** Diff coverage vs a base ref. Requires `coverage.enabled === true`. */
95
- diffCoverage?: DiffOptions;
96
- /** Watch mode: re-run on file changes. */
97
- watch?: WatchOptions;
98
- }
99
-
100
- export interface RunOutcome {
101
- summary: Summary;
102
- coverage?: CoverageSummary;
103
- coverageViolations?: ThresholdViolation[];
104
- diffCoverage?: DiffSummary;
105
- diffCoverageViolations?: ThresholdViolation[];
106
- exitCode: number;
107
- }
108
-
109
- function defaultWorkerEntry(): string {
110
- // Resolve relative to THIS module's directory so it works whether we run
111
- // from `src/cli/run.ts` (dev: src sibling is `.ts`) or from the compiled
112
- // `dist/cli/run.js` (publish: sibling is `.js`). Hardcoding `.ts` made
113
- // the spawned worker open a non-existent `dist/runtime/cli-worker.ts`
114
- // when consumed via the dist tarball, dying silently before the pool's
115
- // frame handler could observe anything.
116
- const here = path.dirname(fileURLToPath(import.meta.url));
117
- const compiled = path.resolve(here, "../runtime/cli-worker.js");
118
- if (existsSync(compiled)) return compiled;
119
- return path.resolve(here, "../runtime/cli-worker.ts");
120
- }
121
-
122
- /**
123
- * Plain-path execution via the native `ream-test-napi` engine: it discovers
124
- * (skipped here — we pass the resolved `files`), spawns the worker pool, drives
125
- * the reporter, and returns aggregated totals. Per-file detail is carried in
126
- * `payload.json` (same shape as `Summary`) for callers that need it; the CLI
127
- * only consumes `exitCode`, and the Rust reporter already streamed per-file
128
- * output, so the reconstructed `Summary` keeps the detail arrays empty.
129
- */
130
- async function runNative(
131
- config: RunConfig,
132
- root: string,
133
- files: string[],
134
- ): Promise<RunOutcome> {
135
- const { run: nativeRun } = getNative();
136
- const payload = await nativeRun({
137
- root,
138
- files,
139
- threads: config.threads,
140
- timeoutMs: config.timeoutMs,
141
- reporter: config.reporter,
142
- workerEntry: config.workerEntry ?? defaultWorkerEntry(),
143
- nodeBin: config.nodeBin ?? process.execPath,
144
- nodeArgs: config.nodeArgs,
145
- useColors: config.useColors ?? process.stdout.isTTY === true,
146
- });
147
- const totals: Totals = {
148
- pass: payload.pass,
149
- fail: payload.fail,
150
- skip: payload.skip,
151
- todo: payload.todo,
152
- fileErrors: payload.fileErrors,
153
- };
154
- const summary: Summary = {
155
- totals,
156
- files: [],
157
- fileErrors: [],
158
- durationMs: payload.durationMs,
159
- };
160
- return { summary, exitCode: payload.exitCode };
161
- }
162
-
163
- export async function run(config: RunConfig): Promise<RunOutcome> {
164
- if (!config.watch?.enabled) return runOnce(config);
165
- const root = path.isAbsolute(config.root)
166
- ? config.root
167
- : path.resolve(config.root);
168
- const debounceMs = config.watch.debounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS;
169
- // When the user has configured custom coverage globs, mirror them in
170
- // the watcher so the two surfaces agree on "files I care about".
171
- // Otherwise fall back to the watch defaults (which intentionally
172
- // include `tests/**` even though coverage doesn't, so editing a test
173
- // triggers a re-run).
174
- const include =
175
- config.watch.include ?? config.coverage?.include ?? DEFAULT_WATCH_INCLUDE;
176
- const exclude = [
177
- ...(config.watch.exclude ??
178
- config.coverage?.exclude ??
179
- DEFAULT_WATCH_EXCLUDE),
180
- ];
181
- return runWatch(
182
- {
183
- root,
184
- include,
185
- exclude,
186
- debounceMs,
187
- signal: config.watch.signal,
188
- },
189
- () => runOnce(config),
190
- );
191
- }
192
-
193
- /** Aggregated coverage + diff-coverage results threaded back into `RunOutcome`. */
194
- interface CoverageOutcome {
195
- coverage?: CoverageSummary;
196
- coverageViolations?: ThresholdViolation[];
197
- diffCoverage?: DiffSummary;
198
- diffCoverageViolations?: ThresholdViolation[];
199
- // Tracks unrecoverable diff-cov failures so the run still exits non-zero
200
- // when diff-cov was supposed to gate the PR (otherwise a thrown error
201
- // in finaliseDiff would silently downgrade exit to the full-tree code).
202
- diffCoverageFailed: boolean;
203
- }
204
-
205
- /** Resolve the explicit file list, or discover, warning when nothing matches. */
206
- async function resolveRunFiles(
207
- config: RunConfig,
208
- root: string,
209
- ): Promise<string[]> {
210
- const files =
211
- config.files && config.files.length > 0
212
- ? config.files.map((f) => (path.isAbsolute(f) ? f : path.resolve(f)))
213
- : await discover(root, config.discovery);
214
-
215
- if (files.length === 0) {
216
- process.stderr.write(
217
- `helix: no test files found under ${root} — check your include/exclude patterns.\n`,
218
- );
219
- }
220
- return files;
221
- }
222
-
223
- /**
224
- * Refuse mis-configured diff coverage up front (before spawning workers) so the
225
- * user fixes the config rather than getting a silent no-op or 0% diff coverage.
226
- */
227
- function assertDiffCoverageConfig(config: RunConfig, root: string): void {
228
- if (!config.diffCoverage?.enabled) return;
229
-
230
- // Diff-cov requires the full-tree coverage to be enabled — it has nothing
231
- // to overlay otherwise.
232
- if (config.coverage?.enabled !== true) {
233
- throw new Error(
234
- "diffCoverage.enabled requires coverage.enabled — diff-cov has nothing to overlay otherwise.",
235
- );
236
- }
237
-
238
- // Diff-cov spawns git in `diffCoverage.cwd` (defaulting to `coverage.root`).
239
- // The two paths must share a common ancestry — either coverage.root is
240
- // under diffCoverage.cwd (common monorepo case: `cwd` = git root,
241
- // `root` = a sub-package), or diffCoverage.cwd is under coverage.root
242
- // (legacy single-repo case). When they're fully disjoint, every diff
243
- // entry resolves to a path the coverage summary never indexes →
244
- // silent 0% diff coverage. Refuse only that case.
245
- if (!config.diffCoverage.cwd) return;
246
- const covRoot = path.resolve(config.coverage?.root ?? root);
247
- const diffCwd = path.resolve(config.diffCoverage.cwd);
248
- const covUnderDiff = path.relative(diffCwd, covRoot);
249
- const diffUnderCov = path.relative(covRoot, diffCwd);
250
- const covDescendantOfDiff =
251
- covUnderDiff !== "" &&
252
- !covUnderDiff.startsWith("..") &&
253
- !path.isAbsolute(covUnderDiff);
254
- const diffDescendantOfCov =
255
- diffUnderCov !== "" &&
256
- !diffUnderCov.startsWith("..") &&
257
- !path.isAbsolute(diffUnderCov);
258
- const equal = covUnderDiff === "";
259
- if (!equal && !covDescendantOfDiff && !diffDescendantOfCov) {
260
- throw new Error(
261
- `diffCoverage.cwd (${diffCwd}) and coverage.root (${covRoot}) are disjoint paths; diff entries would never overlay coverage.`,
262
- );
263
- }
264
- }
265
-
266
- /**
267
- * Finalise full-tree coverage, then overlay diff coverage on top. Each stage is
268
- * isolated: a failed coverage finalise (disk full, permissions) or a thrown
269
- * diff-cov is surfaced to stderr without crashing the run, but a diff-cov
270
- * failure still flips `diffCoverageFailed` so the CI gate stays red.
271
- */
272
- async function finaliseCoverageOutcome(
273
- config: RunConfig & { coverage: CoverageOptions },
274
- root: string,
275
- session: CoverageSession,
276
- ): Promise<CoverageOutcome> {
277
- const out: CoverageOutcome = { diffCoverageFailed: false };
278
- try {
279
- const finalised = await finaliseCoverage({
280
- root: config.coverage.root ?? root,
281
- session,
282
- enabled: true,
283
- include: config.coverage.include,
284
- exclude: config.coverage.exclude,
285
- reporters: config.coverage.reporters,
286
- outputDir: config.coverage.outputDir,
287
- thresholds: config.coverage.thresholds,
288
- });
289
- out.coverage = finalised.summary;
290
- out.coverageViolations = finalised.violations;
291
- if (finalised.textReport) process.stdout.write(finalised.textReport);
292
- if (finalised.violations.length > 0) {
293
- process.stderr.write(`${violationSummary(finalised.violations)}\n`);
294
- }
295
-
296
- // Diff coverage runs AFTER full-tree finalise, using its summary.
297
- if (config.diffCoverage?.enabled && out.coverage) {
298
- await overlayDiffCoverage(config, root, out.coverage, out);
299
- }
300
- } catch (err) {
301
- // Coverage finalisation failed (disk full, permission, etc.) — the test
302
- // summary already printed; surface the failure but don't crash the whole
303
- // run with a stack trace.
304
- process.stderr.write(
305
- `helix-coverage: ${err instanceof Error ? err.message : String(err)}\n`,
306
- );
307
- out.coverageViolations = [];
308
- }
309
- return out;
310
- }
311
-
312
- /** Overlay diff coverage onto an already-finalised full-tree summary. */
313
- async function overlayDiffCoverage(
314
- config: RunConfig & { coverage: CoverageOptions },
315
- root: string,
316
- coverage: CoverageSummary,
317
- out: CoverageOutcome,
318
- ): Promise<void> {
319
- if (!config.diffCoverage?.enabled) return;
320
- try {
321
- const diff = await finaliseDiff({
322
- enabled: true,
323
- root: config.coverage.root ?? root,
324
- coverage,
325
- base: config.diffCoverage.base,
326
- thresholds: config.diffCoverage.thresholds,
327
- outputDir: config.diffCoverage.outputDir ?? config.coverage.outputDir,
328
- cwd: config.diffCoverage.cwd,
329
- });
330
- if (diff.warning) {
331
- process.stderr.write(`${diff.warning}\n`);
332
- return;
333
- }
334
- out.diffCoverage = diff.summary;
335
- out.diffCoverageViolations = diff.violations;
336
- if (diff.textReport) process.stdout.write(diff.textReport);
337
- if (diff.violations.length > 0) {
338
- process.stderr.write(`${diffViolationSummary(diff.violations)}\n`);
339
- }
340
- } catch (err) {
341
- process.stderr.write(
342
- `helix-diff-cov: ${err instanceof Error ? err.message : String(err)}\n`,
343
- );
344
- // Don't let a thrown finaliseDiff masquerade as a clean run — the user
345
- // explicitly opted into diff-cov and a CI gate around it must still fail.
346
- out.diffCoverageFailed = true;
347
- }
348
- }
349
-
350
- async function runOnce(config: RunConfig): Promise<RunOutcome> {
351
- const started = Date.now();
352
- const root = path.isAbsolute(config.root)
353
- ? config.root
354
- : path.resolve(config.root);
355
-
356
- const files = await resolveRunFiles(config, root);
357
-
358
- // Cutover: the Rust NAPI engine owns the plain discovery + worker-pool +
359
- // reporter + summary path (42-N-orchestrator). Delegate unless a TS-only
360
- // layer is active — a pluggable reporter instance, coverage, or diff-cov,
361
- // none of which the Rust `run` exposes yet. Watch is already unwrapped by
362
- // `run()` above, so it never reaches here.
363
- if (
364
- !config.reporterInstance &&
365
- config.coverage?.enabled !== true &&
366
- config.diffCoverage?.enabled !== true
367
- ) {
368
- return runNative(config, root, files);
369
- }
370
-
371
- const reporter =
372
- config.reporterInstance ??
373
- makeReporter(
374
- config.reporter,
375
- config.useColors ?? process.stdout.isTTY === true,
376
- );
377
-
378
- const threads = config.threads ?? os.cpus().length;
379
-
380
- assertDiffCoverageConfig(config, root);
381
-
382
- // Open a coverage session BEFORE spawning workers so `NODE_V8_COVERAGE`
383
- // is set in their env. The pool forwards `extraEnv` to every spawn.
384
- const coverageEnabled = config.coverage?.enabled === true;
385
- const session = coverageEnabled ? await openSession() : undefined;
386
-
387
- try {
388
- const { results, errors } = await runPool(
389
- files,
390
- {
391
- workerEntry: config.workerEntry ?? defaultWorkerEntry(),
392
- nodeBin: config.nodeBin,
393
- nodeArgs: config.nodeArgs,
394
- threads,
395
- timeoutMs: config.timeoutMs,
396
- extraEnv: session?.env,
397
- },
398
- reporter,
399
- );
400
-
401
- const summary = buildSummary(results, errors, Date.now() - started);
402
- reporter.onSummary(summary);
403
-
404
- const cov: CoverageOutcome =
405
- session && config.coverage
406
- ? await finaliseCoverageOutcome(
407
- { ...config, coverage: config.coverage },
408
- root,
409
- session,
410
- )
411
- : { diffCoverageFailed: false };
412
-
413
- const baseExit = exitCode(summary);
414
- const coverageExit =
415
- cov.coverageViolations && cov.coverageViolations.length > 0 ? 1 : 0;
416
- const diffCoverageExit =
417
- (cov.diffCoverageViolations && cov.diffCoverageViolations.length > 0) ||
418
- cov.diffCoverageFailed
419
- ? 1
420
- : 0;
421
- return {
422
- summary,
423
- coverage: cov.coverage,
424
- coverageViolations: cov.coverageViolations,
425
- diffCoverage: cov.diffCoverage,
426
- diffCoverageViolations: cov.diffCoverageViolations,
427
- exitCode: Math.max(baseExit, coverageExit, diffCoverageExit),
428
- };
429
- } finally {
430
- // Belt-and-braces: if `runPool` itself rejected before
431
- // `finaliseCoverage` ran (which normally cleans the temp dir), we
432
- // still remove the session dir here so failed runs don't leak under
433
- // `.helix-coverage/`.
434
- if (session) {
435
- await rm(session.envDir, { recursive: true, force: true }).catch(
436
- () => {},
437
- );
438
- }
439
- }
440
- }
@@ -1,42 +0,0 @@
1
- import type { FileResult } from "../runtime/run.js";
2
- import type { WorkerErrorMessage } from "./pool.js";
3
-
4
- export interface Totals {
5
- pass: number;
6
- fail: number;
7
- skip: number;
8
- todo: number;
9
- fileErrors: number;
10
- }
11
-
12
- export interface Summary {
13
- totals: Totals;
14
- files: FileResult[];
15
- fileErrors: WorkerErrorMessage[];
16
- durationMs: number;
17
- }
18
-
19
- export function buildSummary(
20
- files: FileResult[],
21
- fileErrors: WorkerErrorMessage[],
22
- durationMs: number,
23
- ): Summary {
24
- const totals: Totals = {
25
- pass: 0,
26
- fail: 0,
27
- skip: 0,
28
- todo: 0,
29
- fileErrors: fileErrors.length,
30
- };
31
- for (const f of files) {
32
- totals.pass += f.totals.pass;
33
- totals.fail += f.totals.fail;
34
- totals.skip += f.totals.skip;
35
- totals.todo += f.totals.todo;
36
- }
37
- return { totals, files, fileErrors, durationMs };
38
- }
39
-
40
- export function exitCode(summary: Summary): number {
41
- return summary.totals.fail > 0 || summary.totals.fileErrors > 0 ? 1 : 0;
42
- }
@@ -1,159 +0,0 @@
1
- /**
2
- * Watch-mode run loop: runs `runOnce` once on startup, installs the
3
- * watcher, and re-invokes `runOnce` whenever a coalesced change burst
4
- * fires. Re-runs are serialised — at most one queued follow-up while a
5
- * run is in flight; further bursts during that window are dropped (the
6
- * queued run will pick up the latest disk state).
7
- *
8
- * On SIGINT (or an aborted external `AbortSignal`), the loop closes
9
- * the watcher, waits for any in-flight run, and resolves with the
10
- * LAST `RunOutcome` (with `exitCode: 0`, since a failing run during
11
- * interactive watch should not poison the exit status — failures
12
- * already surface as test output).
13
- */
14
-
15
- import type { RunOutcome } from "../run.js";
16
- import { createWatcher, type WatcherHandle } from "./watcher.js";
17
-
18
- export interface RunWatchOptions {
19
- root: string;
20
- include: string[];
21
- exclude: string[];
22
- debounceMs: number;
23
- /** Optional external abort. When triggered, behaves like SIGINT.
24
- * Lets tests drive shutdown without `process.emit("SIGINT")` (which
25
- * collides with vitest's own signal handlers). */
26
- signal?: AbortSignal;
27
- }
28
-
29
- const BANNER_WAITING = "[helix] watching for changes… (Ctrl-C to exit)";
30
- const BANNER_RERUN = "[helix] change detected, re-running…";
31
-
32
- function fallbackOutcome(): RunOutcome {
33
- return {
34
- summary: {
35
- totals: { pass: 0, fail: 0, skip: 0, todo: 0, fileErrors: 0 },
36
- files: [],
37
- fileErrors: [],
38
- durationMs: 0,
39
- },
40
- exitCode: 0,
41
- };
42
- }
43
-
44
- export async function runWatch(
45
- opts: RunWatchOptions,
46
- runOnce: () => Promise<RunOutcome>,
47
- ): Promise<RunOutcome> {
48
- // Initialise upfront so the final return never reads `undefined`,
49
- // even if `runOnce` throws and the catch path's fallback construction
50
- // itself throws (defence-in-depth against a brittle cast).
51
- let lastOutcome: RunOutcome = fallbackOutcome();
52
- let inFlight: Promise<void> | undefined;
53
- let queued = false;
54
- let closing = false;
55
- let watcher: WatcherHandle | undefined;
56
- let resolveSettled: () => void;
57
- const settled = new Promise<void>((resolve) => {
58
- resolveSettled = resolve;
59
- });
60
-
61
- function beginShutdown(): void {
62
- if (closing) return;
63
- closing = true;
64
- void (async () => {
65
- if (watcher) await watcher.close();
66
- if (inFlight) await inFlight;
67
- resolveSettled();
68
- })();
69
- }
70
-
71
- const onSigint = (): void => beginShutdown();
72
- const onAbort = (): void => beginShutdown();
73
- process.on("SIGINT", onSigint);
74
- if (opts.signal) {
75
- if (opts.signal.aborted) {
76
- closing = true;
77
- } else {
78
- opts.signal.addEventListener("abort", onAbort, { once: true });
79
- }
80
- }
81
-
82
- function trigger(): void {
83
- if (closing) return;
84
- if (inFlight) {
85
- queued = true;
86
- return;
87
- }
88
- process.stdout.write(`${BANNER_RERUN}\n`);
89
- inFlight = (async () => {
90
- try {
91
- lastOutcome = await runOnce();
92
- } catch (err) {
93
- process.stderr.write(
94
- `helix-watch: run failed: ${err instanceof Error ? err.message : String(err)}\n`,
95
- );
96
- } finally {
97
- inFlight = undefined;
98
- if (!closing) {
99
- process.stdout.write(`${BANNER_WAITING}\n`);
100
- }
101
- if (queued && !closing) {
102
- queued = false;
103
- trigger();
104
- }
105
- }
106
- })();
107
- }
108
-
109
- // Initial run — drive it through the same in-flight machinery so a
110
- // SIGINT/abort arriving during the first run is handled exactly like
111
- // one arriving during a re-run (handler awaits `inFlight`, then
112
- // resolves).
113
- inFlight = (async () => {
114
- try {
115
- lastOutcome = await runOnce();
116
- } catch (err) {
117
- process.stderr.write(
118
- `helix-watch: run failed: ${err instanceof Error ? err.message : String(err)}\n`,
119
- );
120
- } finally {
121
- inFlight = undefined;
122
- }
123
- })();
124
- await inFlight;
125
-
126
- if (closing) {
127
- process.off("SIGINT", onSigint);
128
- opts.signal?.removeEventListener("abort", onAbort);
129
- return { ...lastOutcome, exitCode: 0 };
130
- }
131
-
132
- process.stdout.write(`${BANNER_WAITING}\n`);
133
-
134
- let watcherFatalError: Error | undefined;
135
- watcher = createWatcher({
136
- root: opts.root,
137
- include: opts.include,
138
- exclude: opts.exclude,
139
- debounceMs: opts.debounceMs,
140
- onChange: () => trigger(),
141
- onError: (err) => {
142
- watcherFatalError = err;
143
- beginShutdown();
144
- },
145
- });
146
-
147
- await settled;
148
-
149
- process.off("SIGINT", onSigint);
150
- opts.signal?.removeEventListener("abort", onAbort);
151
-
152
- if (watcherFatalError) {
153
- process.stderr.write(
154
- `helix-watch: watcher error — ${watcherFatalError.message}\n`,
155
- );
156
- }
157
-
158
- return { ...lastOutcome, exitCode: 0 };
159
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * Watch-mode public types. Kept in their own file so `bin/helix.js` and
3
- * `run.ts` can import without pulling in the watcher implementation.
4
- */
5
-
6
- export interface WatchOptions {
7
- enabled: boolean;
8
- /** Debounce window in ms — events within the window collapse to one
9
- * re-run. Default 200, range [1, 5_000]. */
10
- debounceMs?: number;
11
- /** Override the watcher's include globs. Defaults to `coverage.include`
12
- * when set, otherwise the watch defaults (`src/**`, `tests/**`,
13
- * `test/**`). */
14
- include?: string[];
15
- /** Override the watcher's exclude globs. Defaults to `coverage.exclude`
16
- * when set, otherwise the watch defaults (`node_modules/**`,
17
- * `.helix-coverage/**`, etc.). */
18
- exclude?: string[];
19
- /** Optional `AbortSignal` to drive shutdown without a real SIGINT —
20
- * primarily a test seam. */
21
- signal?: AbortSignal;
22
- }