@specific.dev/spectest 0.127.0 → 0.129.0

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 (46) hide show
  1. package/README.md +54 -0
  2. package/dist/browser.d.ts +12 -5
  3. package/dist/browser.js +13 -13
  4. package/dist/components/k3s-coverage.d.ts +24 -0
  5. package/dist/components/k3s-coverage.js +259 -0
  6. package/dist/components/k3s-trust.d.ts +4 -2
  7. package/dist/components/k3s-trust.js +13 -0
  8. package/dist/components/k3s.js +31 -0
  9. package/dist/coverage/rust.rs +102 -0
  10. package/dist/coverage-bun.d.ts +10 -0
  11. package/dist/coverage-bun.js +32 -0
  12. package/dist/coverage-rust.d.ts +23 -0
  13. package/dist/coverage-rust.js +71 -0
  14. package/dist/coverage.d.ts +11 -5
  15. package/dist/coverage.js +70 -13
  16. package/dist/daemon.js +27 -2
  17. package/dist/harness/component-images.d.ts +1 -0
  18. package/dist/harness/component-images.js +4 -2
  19. package/dist/harness/coverage-target.d.ts +10 -0
  20. package/dist/harness/coverage-target.js +54 -0
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +10 -1
  23. package/dist/recorder.d.ts +29 -2
  24. package/dist/terminal.d.ts +31 -17
  25. package/dist/terminal.js +261 -105
  26. package/package.json +1 -1
  27. package/src/browser-navigation.test.ts +36 -0
  28. package/src/browser.ts +26 -18
  29. package/src/components/k3s-coverage.test.ts +160 -0
  30. package/src/components/k3s-coverage.ts +234 -0
  31. package/src/components/k3s-trust.ts +14 -2
  32. package/src/components/k3s.ts +26 -0
  33. package/src/coverage/rust.rs +102 -0
  34. package/src/coverage-bun.test.ts +103 -0
  35. package/src/coverage-bun.ts +40 -0
  36. package/src/coverage-rust.test.ts +74 -0
  37. package/src/coverage-rust.ts +79 -0
  38. package/src/coverage.test.ts +4 -4
  39. package/src/coverage.ts +74 -13
  40. package/src/daemon.ts +22 -2
  41. package/src/harness/component-images.ts +5 -2
  42. package/src/harness/coverage-target.ts +55 -0
  43. package/src/index.ts +12 -3
  44. package/src/recorder.ts +22 -2
  45. package/src/terminal.test.ts +324 -0
  46. package/src/terminal.ts +269 -119
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
41
41
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "./locator.js";
42
42
  import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
43
43
  import { describeUrlPattern } from "./url-match.js";
44
- import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
44
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
45
45
  import { resolveExistingProjectPath } from "./project-files.js";
46
46
  export { ca } from "./environment-ca.js";
47
47
  // Low-level ingress primitives + the framework lowering that the friendly
@@ -185,6 +185,15 @@ function expandServiceGroups(input) {
185
185
  // Rewrite relative dependsOn to final keys; a spread keeps function
186
186
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
187
187
  const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
188
+ // Component-owned nested containers keep their collection when callers
189
+ // add ordinary service coverage (for example browser coverage on k3s).
190
+ const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
191
+ if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
192
+ const extra = primaryOverrides.coverage;
193
+ final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
194
+ ...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
195
+ ] };
196
+ }
188
197
  if (final.dependsOn?.length) {
189
198
  final.dependsOn = final.dependsOn.map((d) => partKeys.has(d) ? naming.key(d) : d);
190
199
  }
@@ -407,6 +407,9 @@ export interface RecordedBrowserLocatorPart {
407
407
  }
408
408
  export interface BrowserEvent extends BaseEvent {
409
409
  kind: "browser";
410
+ /** Explicit navigation options, when supplied by the test. */
411
+ timeout?: number;
412
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
410
413
  action: BrowserAction;
411
414
  target?: RecordedBrowserLocatorPart[];
412
415
  destinationTarget?: RecordedBrowserLocatorPart[];
@@ -534,6 +537,18 @@ export interface TerminalEvent extends BaseEvent {
534
537
  }
535
538
  /** Action taken on an open interactive terminal session. */
536
539
  export type TerminalStepAction = "send" | "sendLine" | "press" | "waitFor" | "exit" | "close";
540
+ export type TerminalWaitMatcher = {
541
+ kind: "text";
542
+ value: string;
543
+ } | {
544
+ kind: "regex";
545
+ value: string;
546
+ flags: string;
547
+ } | {
548
+ kind: "custom";
549
+ description?: string;
550
+ };
551
+ export type TerminalWaitOutcome = "matched" | "timed_out" | "failure_matched" | "predicate_error" | "process_exited" | "cancelled";
537
552
  /**
538
553
  * One operation on an open interactive terminal — analogous to
539
554
  * `BrowserEvent`. The session's heavy asciicast frames live on the
@@ -557,9 +572,21 @@ export interface TerminalStepEvent extends BaseEvent {
557
572
  description?: string;
558
573
  /** How many polls `waitFor` made. */
559
574
  attempts?: number;
560
- /** Whether `waitFor` matched (false means it timed out). */
575
+ /** Whether `waitFor` matched; see outcome for failure reasons. */
561
576
  matched?: boolean;
562
- /** Exit code observed on `close`. */
577
+ /** The condition being observed, including an optional custom-predicate description. */
578
+ matcher?: TerminalWaitMatcher;
579
+ outcome?: TerminalWaitOutcome;
580
+ failureMatchers?: {
581
+ description: string;
582
+ matcher: TerminalWaitMatcher;
583
+ }[];
584
+ intervalMs?: number;
585
+ timeoutMs?: number;
586
+ /** Wait boundaries on the session clock, including time with no output. */
587
+ castStartTimeSec?: number;
588
+ castEndTimeSec?: number;
589
+ /** Exit code observed on exit, close, or a failed wait. */
563
590
  exitCode?: number;
564
591
  /** Rendered screen at the moment of the op (post-action), truncated. */
565
592
  screenPreview: string;
@@ -25,6 +25,25 @@ export interface TerminalOpts {
25
25
  */
26
26
  timeoutMs?: number;
27
27
  }
28
+ /** A visible terminal failure that should stop a wait immediately. */
29
+ export interface TerminalFailureCondition {
30
+ description: string;
31
+ matcher: string | RegExp;
32
+ }
33
+ export interface TerminalWaitOptions {
34
+ timeoutMs?: number;
35
+ intervalMs?: number;
36
+ /** Cancel this observation without closing the terminal. */
37
+ signal?: AbortSignal;
38
+ /** Describe a custom predicate's condition separately from the step title. */
39
+ condition?: string;
40
+ /** Checked against the rendered screen before the success matcher. */
41
+ failOn?: readonly TerminalFailureCondition[];
42
+ }
43
+ export interface TerminalCloseOptions {
44
+ /** Total deadline including graceful exit, kill and pipe drain. Default 10 s. */
45
+ timeoutMs?: number;
46
+ }
28
47
  /** Result of the one-shot `ctx.terminal(...)` convenience. */
29
48
  export interface TerminalResult {
30
49
  /** Full decoded PTY output (raw bytes, ANSI escapes included). Capped. */
@@ -50,7 +69,8 @@ export interface Terminal {
50
69
  /**
51
70
  * Resolves when the underlying process ends — by `close()`, by the
52
71
  * inner command exiting, or by timeout — to a `TerminalResult` with
53
- * the full captured `output` and `exitCode`. Never rejects.
72
+ * the full captured `output` and `exitCode`. Rejects if output cannot
73
+ * be drained after exit.
54
74
  *
55
75
  * The result is provenance-tagged against the `exit` step, so an
56
76
  * `expect((await term.exited).output).toContain(...)` /
@@ -91,29 +111,23 @@ export interface Terminal {
91
111
  * `description` is a short human label that shows up in the event
92
112
  * log so timelines don't fill with anonymous waits.
93
113
  *
94
- * Defaults: 5 s total timeout, 100 ms between polls. Throws on
95
- * timeout.
114
+ * Defaults: 10 s total timeout, 100 ms between polls. Throws on timeout,
115
+ * a failure condition, predicate error, cancellation, or process exit before
116
+ * a match. On exit the final drained screen is checked first. Async custom
117
+ * predicates are supported; cancellation stops polling but cannot forcibly
118
+ * cancel work started by the predicate itself.
96
119
  */
97
- waitFor(description: string, matcher: string, opts?: {
98
- timeoutMs?: number;
99
- intervalMs?: number;
100
- }): Promise<Wrapped<string>>;
101
- waitFor(description: string, matcher: RegExp, opts?: {
102
- timeoutMs?: number;
103
- intervalMs?: number;
104
- }): Promise<Wrapped<RegExpMatchArray>>;
105
- waitFor<T = unknown>(description: string, matcher: (screen: string, output: string) => T, opts?: {
106
- timeoutMs?: number;
107
- intervalMs?: number;
108
- }): Promise<Wrapped<NonNullable<Awaited<T>>>>;
120
+ waitFor(description: string, matcher: string, opts?: TerminalWaitOptions): Promise<Wrapped<string>>;
121
+ waitFor(description: string, matcher: RegExp, opts?: TerminalWaitOptions): Promise<Wrapped<RegExpMatchArray>>;
122
+ waitFor<T = unknown>(description: string, matcher: (screen: string, output: string) => T, opts?: TerminalWaitOptions): Promise<Wrapped<NonNullable<Awaited<T>>>>;
109
123
  /**
110
124
  * Tear down. Writes Ctrl-D to stdin to nudge the shell out, then
111
125
  * SIGKILLs after a grace period. Idempotent. Resolves to a
112
126
  * `TerminalResult` (tagged against the `close` step) so you can assert
113
127
  * on the transcript of a session that doesn't self-terminate. Optional
114
- * — terminals are not auto-closed when a test ends.
128
+ * — terminals are not auto-closed when a test ends. Rejects on cleanup timeout.
115
129
  */
116
- close(): Promise<Wrapped<TerminalResult>>;
130
+ close(opts?: TerminalCloseOptions): Promise<Wrapped<TerminalResult>>;
117
131
  }
118
132
  /**
119
133
  * The concrete handle `openTerminal` returns. Extends the public
package/dist/terminal.js CHANGED
@@ -20,7 +20,7 @@
20
20
  //
21
21
  // PTY-on-the-host trick: the daemon runs under systemd with no controlling
22
22
  // TTY, so `docker exec -it` (the docker CLI rejects -it when its own stdin
23
- // isn't a TTY) needs a PTY wrapper. `script -qfc 'CMD' /dev/null` allocates
23
+ // isn't a TTY) needs a PTY wrapper. `script -qfec 'CMD' /dev/null` allocates
24
24
  // a PTY for CMD; we set the slave's size with `stty rows R cols C` inside
25
25
  // the wrapped command so the container app sees the cols/rows the caller
26
26
  // asked for. `script(1)` ships in util-linux which is essential on Debian,
@@ -97,9 +97,10 @@ export async function openTerminal(args) {
97
97
  // 80x24, but the daemon has none, so it's whatever util-linux picks
98
98
  // as the fallback).
99
99
  const inner = `stty rows ${rows} cols ${cols} 2>/dev/null; exec ${shellQuote(["docker", ...dockerArgs])}`;
100
- // `script -q -f -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
100
+ // `script -q -f -e -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
101
101
  // after each write, output discarded — we capture via stdout pipe).
102
- const spawnArgs = ["script", "-qfc", inner, "/dev/null"];
102
+ // -e preserves the child command status; without it script reports 0 on failure.
103
+ const spawnArgs = ["script", "-qfec", inner, "/dev/null"];
103
104
  const start = Date.now();
104
105
  const proc = Bun.spawn(spawnArgs, {
105
106
  stdin: "pipe",
@@ -119,14 +120,22 @@ export async function openTerminal(args) {
119
120
  let exitCode;
120
121
  let closed = false;
121
122
  let resolveExit;
122
- const rawExited = new Promise((res) => {
123
+ let rejectExit;
124
+ let exitSettled = false;
125
+ const exitWaiters = new Set();
126
+ const cancelWaits = new Set();
127
+ const rawExited = new Promise((res, reject) => {
123
128
  resolveExit = res;
129
+ rejectExit = reject;
124
130
  });
131
+ // Lifecycle errors are observed by waitFor/exited/close, possibly much later.
132
+ void rawExited.catch(() => { });
125
133
  // Cast time at the moment the process actually exited. Captured by
126
134
  // the IIFE below so the `exit` step we record points the player at
127
135
  // the very end of the visible session.
128
136
  let exitCastTimeSec;
129
- const decoder = new TextDecoder("utf-8", { fatal: false });
137
+ let outputEnded = false;
138
+ const readers = new Set();
130
139
  // Subscribers waiting for the next frame to arrive. Used by send /
131
140
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
132
141
  // frame instead of microseconds before it.
@@ -143,13 +152,16 @@ export async function openTerminal(args) {
143
152
  // millisecond as the following `sendLine`'s echo showed that echo
144
153
  // — the replay ran one step ahead of the step list.
145
154
  let lastFrameT = 0;
155
+ let lastRenderedFrameT = 0;
146
156
  const ingest = (chunk) => {
157
+ if (outputEnded)
158
+ return;
147
159
  let t = (Date.now() - start) / 1000;
148
160
  if (t <= lastFrameT)
149
161
  t = lastFrameT + 0.001;
150
162
  lastFrameT = t;
151
163
  sink.pushFrame(t, chunk);
152
- emu.write(chunk);
164
+ emu.write(chunk, () => { lastRenderedFrameT = t; });
153
165
  if (output.length < OUTPUT_CAP_BYTES) {
154
166
  const room = OUTPUT_CAP_BYTES - output.length;
155
167
  output += chunk.length > room ? chunk.slice(0, room) : chunk;
@@ -173,21 +185,31 @@ export async function openTerminal(args) {
173
185
  if (done)
174
186
  return;
175
187
  done = true;
188
+ clearTimeout(timeout);
189
+ const index = frameWaiters.indexOf(finish);
190
+ if (index >= 0)
191
+ frameWaiters.splice(index, 1);
176
192
  resolve();
177
193
  };
194
+ const timeout = setTimeout(finish, timeoutMs);
178
195
  frameWaiters.push(finish);
179
- setTimeout(finish, timeoutMs);
180
196
  });
181
197
  };
182
198
  const drainStream = async (stream) => {
183
199
  if (!stream)
184
200
  return;
185
201
  const reader = stream.getReader();
202
+ const decoder = new TextDecoder("utf-8", { fatal: false });
203
+ readers.add(reader);
186
204
  try {
187
205
  while (true) {
188
206
  const { done, value } = await reader.read();
189
- if (done)
207
+ if (done) {
208
+ const tail = decoder.decode();
209
+ if (tail)
210
+ ingest(tail);
190
211
  return;
212
+ }
191
213
  if (!value || value.byteLength === 0)
192
214
  continue;
193
215
  const chunk = decoder.decode(value, { stream: true });
@@ -197,15 +219,23 @@ export async function openTerminal(args) {
197
219
  }
198
220
  }
199
221
  finally {
222
+ readers.delete(reader);
200
223
  reader.releaseLock();
201
224
  }
202
225
  };
226
+ const stopOutput = () => {
227
+ outputEnded = true;
228
+ for (const reader of readers)
229
+ void reader.cancel().catch(() => { });
230
+ };
231
+ const flushScreen = () => new Promise((resolve) => emu.write("", resolve));
203
232
  // Drain stdout + stderr concurrently. `script` writes everything to
204
233
  // stdout under `-t`, but we drain stderr anyway in case script(1)
205
234
  // itself emits diagnostics there.
206
235
  const stdout = proc.stdout;
207
236
  const stderr = proc.stderr;
208
237
  const drained = Promise.all([drainStream(stdout), drainStream(stderr)]);
238
+ void drained.catch(() => { });
209
239
  // Race the optional session-level timeout. For interactive terminals
210
240
  // we usually have no timeout; the wrapper for one-shot supplies one.
211
241
  let timer;
@@ -221,39 +251,42 @@ export async function openTerminal(args) {
221
251
  }
222
252
  }, opts.timeoutMs);
223
253
  }
224
- // When the process exits, drain whatever's left and resolve `exited`.
225
- // We don't await `drained` here — it's already running and will
226
- // settle on its own; the explicit `await drained` happens in close().
227
- (async () => {
228
- const code = await proc.exited;
229
- exited = true;
230
- exitCode = code;
231
- if (timer)
232
- clearTimeout(timer);
233
- // Let `drained` finish so the final asciicast frames land before we
234
- // mark the sink closed. drainStream is bounded by the OS pipe being
235
- // closed when proc exits, so this won't hang.
254
+ // A child may inherit a pipe after the PTY wrapper exits. Bound the drain,
255
+ // and flush xterm's asynchronous writes before exposing the final screen.
256
+ void (async () => {
236
257
  try {
237
- await drained;
258
+ const code = await proc.exited;
259
+ exited = true;
260
+ exitCode = code;
261
+ if (timer)
262
+ clearTimeout(timer);
263
+ await within(drained.then(flushScreen), 5_000, "Terminal output did not finish draining within 5000ms");
264
+ exitCastTimeSec = lastRenderedFrameT + 0.0005;
265
+ sink.markClosed();
266
+ resolveExit(code);
238
267
  }
239
- catch {
240
- /* ignore */
268
+ catch (error) {
269
+ stopOutput();
270
+ sink.markClosed();
271
+ rejectExit(error);
272
+ }
273
+ finally {
274
+ exitSettled = true;
275
+ if (timer)
276
+ clearTimeout(timer);
277
+ for (const wake of exitWaiters)
278
+ wake();
279
+ exitWaiters.clear();
241
280
  }
242
- // Record cast time AFTER drain, so the `exit` step we record below
243
- // points the player at the very last frame (the program's final
244
- // output before EOF). Same between-frames stamp as `recordOpEvent`.
245
- exitCastTimeSec = lastFrameT + 0.0005;
246
- sink.markClosed();
247
- resolveExit(code);
248
281
  })();
249
282
  // Helper to write raw bytes to the PTY. We treat stdin as binary
250
283
  // pipes (Bun's `proc.stdin` is a Web `WritableStream<Uint8Array>` or
251
284
  // a FileSink-like depending on the version — use the FileSink-style
252
285
  // `write(string)` which both shapes accept).
253
286
  const stdinWriter = proc.stdin;
254
- const writeStdin = async (data) => {
255
- if (exited || closed || !stdinWriter)
256
- return;
287
+ const writeStdin = async (data, closing = false) => {
288
+ if (exited || (closed && !closing) || !stdinWriter)
289
+ throw new Error(`Terminal ${JSON.stringify(service)} is ${exited ? "exited" : "closed"}`);
257
290
  stdinWriter.write(data);
258
291
  try {
259
292
  const r = stdinWriter.flush?.();
@@ -326,9 +359,17 @@ export async function openTerminal(args) {
326
359
  const getExited = () => {
327
360
  if (exitedAccessor)
328
361
  return exitedAccessor;
329
- const resv = reserveEvent();
330
362
  exitedAccessor = (async () => {
331
- const code = await rawExited;
363
+ let code;
364
+ try {
365
+ code = await rawExited;
366
+ }
367
+ catch (error) {
368
+ recordOpEvent("exit", { exitCode }, Date.now(), errorMessage(error));
369
+ throw error;
370
+ }
371
+ // Reserve on actual completion, not when .exited is read for a race.
372
+ const resv = reserveEvent();
332
373
  const result = buildResult(code);
333
374
  // No recorder (eval/setup): there's no event to nest under, but we
334
375
  // still wrap (with an undefined seq) so the accessor's wrapped type is
@@ -353,6 +394,7 @@ export async function openTerminal(args) {
353
394
  })();
354
395
  return exitedAccessor;
355
396
  };
397
+ let closePromise;
356
398
  const handle = {
357
399
  cols,
358
400
  rows,
@@ -424,93 +466,207 @@ export async function openTerminal(args) {
424
466
  return renderScreen();
425
467
  },
426
468
  async waitFor(description, matcher, waitOpts) {
427
- const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
428
- const intervalMs = waitOpts?.intervalMs ?? 100;
469
+ const timeoutMs = durationOption(waitOpts?.timeoutMs, DEFAULT_ACTION_TIMEOUT_MS, "timeoutMs");
470
+ const intervalMs = durationOption(waitOpts?.intervalMs, 100, "intervalMs");
429
471
  const t = Date.now();
430
472
  const resv = reserveEvent();
431
- const deadline = t + timeoutMs;
473
+ const castStartTimeSec = Math.max((t - start) / 1000, lastFrameT + 0.0005);
474
+ const condition = typeof matcher === "function"
475
+ ? { kind: "custom", ...(waitOpts?.condition ? { description: waitOpts.condition } : {}) }
476
+ : describeMatcher(matcher);
477
+ const failureMatchers = waitOpts?.failOn?.map(({ description, matcher }) => ({
478
+ description, matcher: describeMatcher(matcher),
479
+ }));
432
480
  let attempts = 0;
433
- const check = () => {
434
- attempts++;
435
- const scr = renderScreen();
436
- if (typeof matcher === "string") {
437
- return scr.includes(matcher) ? matcher : null;
438
- }
439
- if (matcher instanceof RegExp) {
440
- const m = scr.match(matcher);
441
- return m ?? null;
442
- }
443
- return matcher(scr, output);
481
+ let observed;
482
+ let outcome = "predicate_error";
483
+ const control = new AbortController();
484
+ const cancel = () => {
485
+ outcome = "cancelled";
486
+ control.abort(waitOpts?.signal?.reason ?? new Error(`waitFor ${JSON.stringify(description)} cancelled: terminal closed`));
444
487
  };
445
- while (true) {
446
- const v = check();
447
- if (v) {
448
- const seq = recordOpEvent("waitFor", { description, attempts, matched: true }, t, undefined, resv);
449
- // Tag the matched value so an `expect(...)` on it links its
450
- // assertion back to this waitFor step (sourceSeq) instead of
451
- // rendering as an orphan sidebar row. Mirrors how http/db/exec
452
- // results carry their op's seq. `as never` lets this single
453
- // body satisfy the three overload return types.
454
- return wrap(v, seq);
455
- }
456
- if (Date.now() >= deadline) {
457
- const err = `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`;
458
- recordOpEvent("waitFor", { description, attempts, matched: false }, t, err, resv);
459
- throw new Error(err);
488
+ const timeout = setTimeout(() => {
489
+ outcome = "timed_out";
490
+ control.abort(new Error(`waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`));
491
+ }, timeoutMs);
492
+ cancelWaits.add(cancel);
493
+ waitOpts?.signal?.addEventListener("abort", cancel, { once: true });
494
+ if (waitOpts?.signal?.aborted || closed)
495
+ cancel();
496
+ const waitFields = () => ({
497
+ description, attempts, matched: outcome === "matched", outcome,
498
+ matcher: condition, failureMatchers, timeoutMs, intervalMs,
499
+ ...(outcome === "process_exited" ? { exitCode } : {}),
500
+ castStartTimeSec,
501
+ castEndTimeSec: castStartTimeSec + (Date.now() - t) / 1000,
502
+ });
503
+ const pause = () => new Promise((resolve, reject) => {
504
+ const cleanup = () => {
505
+ clearTimeout(poll);
506
+ exitWaiters.delete(wake);
507
+ control.signal.removeEventListener("abort", abort);
508
+ };
509
+ const wake = () => { cleanup(); resolve(); };
510
+ const abort = () => { cleanup(); reject(control.signal.reason); };
511
+ const poll = setTimeout(wake, intervalMs);
512
+ exitWaiters.add(wake);
513
+ control.signal.addEventListener("abort", abort, { once: true });
514
+ if (control.signal.aborted)
515
+ abort();
516
+ else if (exitSettled)
517
+ wake();
518
+ });
519
+ try {
520
+ while (true) {
521
+ control.signal.throwIfAborted();
522
+ if (exited || exitSettled) {
523
+ try {
524
+ await withSignal(rawExited, control.signal);
525
+ }
526
+ catch (error) {
527
+ if (!control.signal.aborted)
528
+ outcome = "process_exited";
529
+ throw error;
530
+ }
531
+ }
532
+ control.signal.throwIfAborted();
533
+ const checkedFinalScreen = exitSettled;
534
+ const screen = renderScreen();
535
+ const preview = truncateUtf8(screen);
536
+ observed = { castTimeSec: lastRenderedFrameT + 0.0005,
537
+ screenPreview: preview.value, screenTruncated: preview.truncated };
538
+ attempts++;
539
+ for (const failure of waitOpts?.failOn ?? []) {
540
+ if (matchScreen(screen, failure.matcher)) {
541
+ outcome = "failure_matched";
542
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: ${failure.description}`);
543
+ }
544
+ }
545
+ const observedOutput = output;
546
+ const value = typeof matcher === "function"
547
+ ? await withSignal(Promise.resolve().then(() => {
548
+ control.signal.throwIfAborted();
549
+ return matcher(screen, observedOutput);
550
+ }), control.signal)
551
+ : matchScreen(screen, matcher);
552
+ control.signal.throwIfAborted();
553
+ if (value) {
554
+ outcome = "matched";
555
+ const seq = recordOpEvent("waitFor", { ...waitFields(), ...observed }, t, undefined, resv);
556
+ return wrap(value, seq);
557
+ }
558
+ if (exitSettled && !checkedFinalScreen)
559
+ continue;
560
+ if (exitSettled) {
561
+ outcome = "process_exited";
562
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: terminal exited with code ${exitCode} before the condition matched`);
563
+ }
564
+ await pause();
460
565
  }
461
- await new Promise((r) => setTimeout(r, intervalMs));
566
+ }
567
+ catch (error) {
568
+ recordOpEvent("waitFor", { ...waitFields(),
569
+ ...((outcome === "predicate_error" || outcome === "failure_matched") ? observed : {}),
570
+ }, t, errorMessage(error), resv);
571
+ throw error;
572
+ }
573
+ finally {
574
+ clearTimeout(timeout);
575
+ cancelWaits.delete(cancel);
576
+ waitOpts?.signal?.removeEventListener("abort", cancel);
462
577
  }
463
578
  },
464
- async close() {
465
- // Use `rawExited` rather than `term.exited` everywhere we need a
466
- // raw number internally — the public getter resolves to a wrapped
467
- // result (and records an `exit` step on first read) which we don't
468
- // want as a side-effect of internal teardown logic.
469
- if (closed)
470
- return buildResult(exitCode ?? (await rawExited));
471
- closed = true;
579
+ close(closeOpts) {
580
+ if (closePromise)
581
+ return closePromise;
582
+ const timeoutMs = durationOption(closeOpts?.timeoutMs, 10_000, "timeoutMs");
472
583
  const t = Date.now();
473
584
  const resv = reserveEvent();
474
- // Polite first: send EOF (Ctrl-D). For a login shell this exits
475
- // cleanly. For a long-running command it's a no-op and we fall
476
- // through to SIGKILL.
477
- try {
478
- await writeStdin("\x04");
479
- }
480
- catch {
481
- /* ignore */
482
- }
483
- // Closing stdin signals EOF to the script wrapper too.
484
- try {
485
- proc.stdin?.end?.();
486
- }
487
- catch {
488
- /* ignore */
489
- }
490
- // Grace period before forcibly killing.
491
- const grace = 1_500;
492
- const raced = await Promise.race([
493
- rawExited,
494
- new Promise((r) => setTimeout(() => r("timeout"), grace)),
495
- ]);
496
- if (raced === "timeout") {
585
+ closed = true;
586
+ for (const cancel of cancelWaits)
587
+ cancel();
588
+ closePromise = (async () => {
497
589
  try {
498
- proc.kill();
590
+ if (!exited) {
591
+ // Send EOF even though new user operations are now closed.
592
+ try {
593
+ await writeStdin("\x04", true);
594
+ }
595
+ catch { /* already exited */ }
596
+ try {
597
+ proc.stdin?.end?.();
598
+ }
599
+ catch { /* already closed */ }
600
+ }
601
+ const grace = Math.min(1_500, timeoutMs / 2);
602
+ let graceTimer;
603
+ const finished = await Promise.race([
604
+ rawExited.then(() => true),
605
+ new Promise((resolve) => { graceTimer = setTimeout(() => resolve(false), grace); }),
606
+ ]).finally(() => clearTimeout(graceTimer));
607
+ if (!finished && !exited) {
608
+ try {
609
+ proc.kill("SIGKILL");
610
+ }
611
+ catch { /* process already gone */ }
612
+ }
613
+ const code = await within(rawExited, Math.max(0, timeoutMs - (Date.now() - t)), `Terminal close timed out after ${timeoutMs}ms`);
614
+ const result = buildResult(code);
615
+ const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
616
+ return wrap(result, seq);
499
617
  }
500
- catch {
501
- /* ignore */
618
+ catch (error) {
619
+ if (!exited) {
620
+ try {
621
+ proc.kill("SIGKILL");
622
+ }
623
+ catch { /* best effort */ }
624
+ }
625
+ stopOutput();
626
+ if (timer)
627
+ clearTimeout(timer);
628
+ recordOpEvent("close", { exitCode }, t, errorMessage(error), resv);
629
+ throw error;
502
630
  }
503
- }
504
- const code = await rawExited;
505
- const result = buildResult(code);
506
- const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
507
- // Tag the result against the close step so assertions on the
508
- // transcript of a non-self-terminating session nest under it.
509
- return wrap(result, seq);
631
+ })();
632
+ return closePromise;
510
633
  },
511
634
  };
512
635
  return handle;
513
636
  }
637
+ function errorMessage(error) {
638
+ return error instanceof Error ? error.message : String(error);
639
+ }
640
+ function durationOption(value, fallback, name) {
641
+ const duration = value ?? fallback;
642
+ if (!Number.isFinite(duration) || duration < 0)
643
+ throw new Error(`${name} must be a finite non-negative number`);
644
+ return duration;
645
+ }
646
+ function describeMatcher(matcher) {
647
+ return typeof matcher === "string" ? { kind: "text", value: matcher }
648
+ : { kind: "regex", value: matcher.source, flags: matcher.flags };
649
+ }
650
+ function matchScreen(screen, matcher) {
651
+ return typeof matcher === "string" ? (screen.includes(matcher) ? matcher : null)
652
+ : screen.match(new RegExp(matcher.source, matcher.flags));
653
+ }
654
+ function withSignal(work, signal) {
655
+ return new Promise((resolve, reject) => {
656
+ const abort = () => reject(signal.reason);
657
+ signal.addEventListener("abort", abort, { once: true });
658
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
659
+ if (signal.aborted) {
660
+ signal.removeEventListener("abort", abort);
661
+ abort();
662
+ }
663
+ });
664
+ }
665
+ function within(work, ms, message) {
666
+ const control = new AbortController();
667
+ const timer = setTimeout(() => control.abort(new Error(message)), ms);
668
+ return withSignal(work, control.signal).finally(() => clearTimeout(timer));
669
+ }
514
670
  // ────────────────────────────────────────────────────────────────────────
515
671
  // Key name → byte sequence
516
672
  // ────────────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.127.0",
3
+ "version": "0.129.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",