@specific.dev/spectest 0.128.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.
package/src/terminal.ts 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,
@@ -30,7 +30,7 @@ import { Terminal as XtermHeadless } from "@xterm/headless";
30
30
 
31
31
  import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
32
32
  import type { EventReservation } from "./recorder.js";
33
- import type { TerminalStepAction } from "./recorder.js";
33
+ import type { TerminalStepAction, TerminalWaitMatcher, TerminalWaitOutcome } from "./recorder.js";
34
34
  import { wrap } from "./inspect.js";
35
35
  // One default deadline for "wait until the app shows it", whichever surface
36
36
  // the author is watching — see the constant for why it is what it is.
@@ -64,6 +64,28 @@ export interface TerminalOpts {
64
64
  timeoutMs?: number;
65
65
  }
66
66
 
67
+ /** A visible terminal failure that should stop a wait immediately. */
68
+ export interface TerminalFailureCondition {
69
+ description: string;
70
+ matcher: string | RegExp;
71
+ }
72
+
73
+ export interface TerminalWaitOptions {
74
+ timeoutMs?: number;
75
+ intervalMs?: number;
76
+ /** Cancel this observation without closing the terminal. */
77
+ signal?: AbortSignal;
78
+ /** Describe a custom predicate's condition separately from the step title. */
79
+ condition?: string;
80
+ /** Checked against the rendered screen before the success matcher. */
81
+ failOn?: readonly TerminalFailureCondition[];
82
+ }
83
+
84
+ export interface TerminalCloseOptions {
85
+ /** Total deadline including graceful exit, kill and pipe drain. Default 10 s. */
86
+ timeoutMs?: number;
87
+ }
88
+
67
89
  /** Result of the one-shot `ctx.terminal(...)` convenience. */
68
90
  export interface TerminalResult {
69
91
  /** Full decoded PTY output (raw bytes, ANSI escapes included). Capped. */
@@ -90,7 +112,8 @@ export interface Terminal {
90
112
  /**
91
113
  * Resolves when the underlying process ends — by `close()`, by the
92
114
  * inner command exiting, or by timeout — to a `TerminalResult` with
93
- * the full captured `output` and `exitCode`. Never rejects.
115
+ * the full captured `output` and `exitCode`. Rejects if output cannot
116
+ * be drained after exit.
94
117
  *
95
118
  * The result is provenance-tagged against the `exit` step, so an
96
119
  * `expect((await term.exited).output).toContain(...)` /
@@ -133,23 +156,26 @@ export interface Terminal {
133
156
  * `description` is a short human label that shows up in the event
134
157
  * log so timelines don't fill with anonymous waits.
135
158
  *
136
- * Defaults: 5 s total timeout, 100 ms between polls. Throws on
137
- * timeout.
159
+ * Defaults: 10 s total timeout, 100 ms between polls. Throws on timeout,
160
+ * a failure condition, predicate error, cancellation, or process exit before
161
+ * a match. On exit the final drained screen is checked first. Async custom
162
+ * predicates are supported; cancellation stops polling but cannot forcibly
163
+ * cancel work started by the predicate itself.
138
164
  */
139
165
  waitFor(
140
166
  description: string,
141
167
  matcher: string,
142
- opts?: { timeoutMs?: number; intervalMs?: number },
168
+ opts?: TerminalWaitOptions,
143
169
  ): Promise<Wrapped<string>>;
144
170
  waitFor(
145
171
  description: string,
146
172
  matcher: RegExp,
147
- opts?: { timeoutMs?: number; intervalMs?: number },
173
+ opts?: TerminalWaitOptions,
148
174
  ): Promise<Wrapped<RegExpMatchArray>>;
149
175
  waitFor<T = unknown>(
150
176
  description: string,
151
177
  matcher: (screen: string, output: string) => T,
152
- opts?: { timeoutMs?: number; intervalMs?: number },
178
+ opts?: TerminalWaitOptions,
153
179
  ): Promise<Wrapped<NonNullable<Awaited<T>>>>;
154
180
 
155
181
  /**
@@ -157,9 +183,9 @@ export interface Terminal {
157
183
  * SIGKILLs after a grace period. Idempotent. Resolves to a
158
184
  * `TerminalResult` (tagged against the `close` step) so you can assert
159
185
  * on the transcript of a session that doesn't self-terminate. Optional
160
- * — terminals are not auto-closed when a test ends.
186
+ * — terminals are not auto-closed when a test ends. Rejects on cleanup timeout.
161
187
  */
162
- close(): Promise<Wrapped<TerminalResult>>;
188
+ close(opts?: TerminalCloseOptions): Promise<Wrapped<TerminalResult>>;
163
189
  }
164
190
 
165
191
  /**
@@ -270,9 +296,10 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
270
296
  // 80x24, but the daemon has none, so it's whatever util-linux picks
271
297
  // as the fallback).
272
298
  const inner = `stty rows ${rows} cols ${cols} 2>/dev/null; exec ${shellQuote(["docker", ...dockerArgs])}`;
273
- // `script -q -f -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
299
+ // `script -q -f -e -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
274
300
  // after each write, output discarded — we capture via stdout pipe).
275
- const spawnArgs = ["script", "-qfc", inner, "/dev/null"];
301
+ // -e preserves the child command status; without it script reports 0 on failure.
302
+ const spawnArgs = ["script", "-qfec", inner, "/dev/null"];
276
303
 
277
304
  const start = Date.now();
278
305
  const proc = Bun.spawn(spawnArgs, {
@@ -295,15 +322,23 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
295
322
  let exitCode: number | undefined;
296
323
  let closed = false;
297
324
  let resolveExit!: (code: number) => void;
298
- const rawExited = new Promise<number>((res) => {
325
+ let rejectExit!: (error: unknown) => void;
326
+ let exitSettled = false;
327
+ const exitWaiters = new Set<() => void>();
328
+ const cancelWaits = new Set<() => void>();
329
+ const rawExited = new Promise<number>((res, reject) => {
299
330
  resolveExit = res;
331
+ rejectExit = reject;
300
332
  });
333
+ // Lifecycle errors are observed by waitFor/exited/close, possibly much later.
334
+ void rawExited.catch(() => {});
301
335
  // Cast time at the moment the process actually exited. Captured by
302
336
  // the IIFE below so the `exit` step we record points the player at
303
337
  // the very end of the visible session.
304
338
  let exitCastTimeSec: number | undefined;
305
339
 
306
- const decoder = new TextDecoder("utf-8", { fatal: false });
340
+ let outputEnded = false;
341
+ const readers = new Set<ReadableStreamDefaultReader<Uint8Array>>();
307
342
  // Subscribers waiting for the next frame to arrive. Used by send /
308
343
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
309
344
  // frame instead of microseconds before it.
@@ -320,12 +355,14 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
320
355
  // millisecond as the following `sendLine`'s echo showed that echo
321
356
  // — the replay ran one step ahead of the step list.
322
357
  let lastFrameT = 0;
358
+ let lastRenderedFrameT = 0;
323
359
  const ingest = (chunk: string): void => {
360
+ if (outputEnded) return;
324
361
  let t = (Date.now() - start) / 1000;
325
362
  if (t <= lastFrameT) t = lastFrameT + 0.001;
326
363
  lastFrameT = t;
327
364
  sink.pushFrame(t, chunk);
328
- emu.write(chunk);
365
+ emu.write(chunk, () => { lastRenderedFrameT = t; });
329
366
  if (output.length < OUTPUT_CAP_BYTES) {
330
367
  const room = OUTPUT_CAP_BYTES - output.length;
331
368
  output += chunk.length > room ? chunk.slice(0, room) : chunk;
@@ -348,10 +385,13 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
348
385
  const finish = (): void => {
349
386
  if (done) return;
350
387
  done = true;
388
+ clearTimeout(timeout);
389
+ const index = frameWaiters.indexOf(finish);
390
+ if (index >= 0) frameWaiters.splice(index, 1);
351
391
  resolve();
352
392
  };
393
+ const timeout = setTimeout(finish, timeoutMs);
353
394
  frameWaiters.push(finish);
354
- setTimeout(finish, timeoutMs);
355
395
  });
356
396
  };
357
397
 
@@ -360,19 +400,31 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
360
400
  ): Promise<void> => {
361
401
  if (!stream) return;
362
402
  const reader = stream.getReader();
403
+ const decoder = new TextDecoder("utf-8", { fatal: false });
404
+ readers.add(reader);
363
405
  try {
364
406
  while (true) {
365
407
  const { done, value } = await reader.read();
366
- if (done) return;
408
+ if (done) {
409
+ const tail = decoder.decode();
410
+ if (tail) ingest(tail);
411
+ return;
412
+ }
367
413
  if (!value || value.byteLength === 0) continue;
368
414
  const chunk = decoder.decode(value, { stream: true });
369
415
  if (chunk.length === 0) continue;
370
416
  ingest(chunk);
371
417
  }
372
418
  } finally {
419
+ readers.delete(reader);
373
420
  reader.releaseLock();
374
421
  }
375
422
  };
423
+ const stopOutput = () => {
424
+ outputEnded = true;
425
+ for (const reader of readers) void reader.cancel().catch(() => {});
426
+ };
427
+ const flushScreen = () => new Promise<void>((resolve) => emu.write("", resolve));
376
428
 
377
429
  // Drain stdout + stderr concurrently. `script` writes everything to
378
430
  // stdout under `-t`, but we drain stderr anyway in case script(1)
@@ -380,6 +432,7 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
380
432
  const stdout = proc.stdout as ReadableStream<Uint8Array> | null | undefined;
381
433
  const stderr = proc.stderr as ReadableStream<Uint8Array> | null | undefined;
382
434
  const drained = Promise.all([drainStream(stdout), drainStream(stderr)]);
435
+ void drained.catch(() => {});
383
436
 
384
437
  // Race the optional session-level timeout. For interactive terminals
385
438
  // we usually have no timeout; the wrapper for one-shot supplies one.
@@ -396,28 +449,28 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
396
449
  }, opts.timeoutMs);
397
450
  }
398
451
 
399
- // When the process exits, drain whatever's left and resolve `exited`.
400
- // We don't await `drained` here — it's already running and will
401
- // settle on its own; the explicit `await drained` happens in close().
402
- (async () => {
403
- const code = await proc.exited;
404
- exited = true;
405
- exitCode = code;
406
- if (timer) clearTimeout(timer);
407
- // Let `drained` finish so the final asciicast frames land before we
408
- // mark the sink closed. drainStream is bounded by the OS pipe being
409
- // closed when proc exits, so this won't hang.
452
+ // A child may inherit a pipe after the PTY wrapper exits. Bound the drain,
453
+ // and flush xterm's asynchronous writes before exposing the final screen.
454
+ void (async () => {
410
455
  try {
411
- await drained;
412
- } catch {
413
- /* ignore */
456
+ const code = await proc.exited;
457
+ exited = true;
458
+ exitCode = code;
459
+ if (timer) clearTimeout(timer);
460
+ await within(drained.then(flushScreen), 5_000, "Terminal output did not finish draining within 5000ms");
461
+ exitCastTimeSec = lastRenderedFrameT + 0.0005;
462
+ sink.markClosed();
463
+ resolveExit(code);
464
+ } catch (error) {
465
+ stopOutput();
466
+ sink.markClosed();
467
+ rejectExit(error);
468
+ } finally {
469
+ exitSettled = true;
470
+ if (timer) clearTimeout(timer);
471
+ for (const wake of exitWaiters) wake();
472
+ exitWaiters.clear();
414
473
  }
415
- // Record cast time AFTER drain, so the `exit` step we record below
416
- // points the player at the very last frame (the program's final
417
- // output before EOF). Same between-frames stamp as `recordOpEvent`.
418
- exitCastTimeSec = lastFrameT + 0.0005;
419
- sink.markClosed();
420
- resolveExit(code);
421
474
  })();
422
475
 
423
476
  // Helper to write raw bytes to the PTY. We treat stdin as binary
@@ -425,8 +478,9 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
425
478
  // a FileSink-like depending on the version — use the FileSink-style
426
479
  // `write(string)` which both shapes accept).
427
480
  const stdinWriter = proc.stdin as { write(s: string): unknown; flush?(): unknown } | undefined;
428
- const writeStdin = async (data: string): Promise<void> => {
429
- if (exited || closed || !stdinWriter) return;
481
+ const writeStdin = async (data: string, closing = false): Promise<void> => {
482
+ if (exited || (closed && !closing) || !stdinWriter)
483
+ throw new Error(`Terminal ${JSON.stringify(service)} is ${exited ? "exited" : "closed"}`);
430
484
  stdinWriter.write(data);
431
485
  try {
432
486
  const r = stdinWriter.flush?.();
@@ -505,9 +559,15 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
505
559
  let exitedAccessor: Promise<Wrapped<TerminalResult>> | undefined;
506
560
  const getExited = (): Promise<Wrapped<TerminalResult>> => {
507
561
  if (exitedAccessor) return exitedAccessor;
508
- const resv = reserveEvent();
509
562
  exitedAccessor = (async () => {
510
- const code = await rawExited;
563
+ let code: number;
564
+ try { code = await rawExited; }
565
+ catch (error) {
566
+ recordOpEvent("exit", { exitCode }, Date.now(), errorMessage(error));
567
+ throw error;
568
+ }
569
+ // Reserve on actual completion, not when .exited is read for a race.
570
+ const resv = reserveEvent();
511
571
  const result = buildResult(code);
512
572
  // No recorder (eval/setup): there's no event to nest under, but we
513
573
  // still wrap (with an undefined seq) so the accessor's wrapped type is
@@ -533,6 +593,8 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
533
593
  return exitedAccessor;
534
594
  };
535
595
 
596
+ let closePromise: Promise<Wrapped<TerminalResult>> | undefined;
597
+
536
598
  const handle: InternalTerminal = {
537
599
  cols,
538
600
  rows,
@@ -615,102 +677,190 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
615
677
  async waitFor(
616
678
  description: string,
617
679
  matcher: string | RegExp | ((screen: string, output: string) => unknown),
618
- waitOpts?: { timeoutMs?: number; intervalMs?: number },
680
+ waitOpts?: TerminalWaitOptions,
619
681
  ) {
620
- const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
621
- const intervalMs = waitOpts?.intervalMs ?? 100;
682
+ const timeoutMs = durationOption(waitOpts?.timeoutMs, DEFAULT_ACTION_TIMEOUT_MS, "timeoutMs");
683
+ const intervalMs = durationOption(waitOpts?.intervalMs, 100, "intervalMs");
622
684
  const t = Date.now();
623
685
  const resv = reserveEvent();
624
- const deadline = t + timeoutMs;
686
+ const castStartTimeSec = Math.max((t - start) / 1000, lastFrameT + 0.0005);
687
+ const condition: TerminalWaitMatcher = typeof matcher === "function"
688
+ ? { kind: "custom", ...(waitOpts?.condition ? { description: waitOpts.condition } : {}) }
689
+ : describeMatcher(matcher);
690
+ const failureMatchers = waitOpts?.failOn?.map(({ description, matcher }) => ({
691
+ description, matcher: describeMatcher(matcher),
692
+ }));
625
693
  let attempts = 0;
626
- const check = (): unknown => {
627
- attempts++;
628
- const scr = renderScreen();
629
- if (typeof matcher === "string") {
630
- return scr.includes(matcher) ? matcher : null;
631
- }
632
- if (matcher instanceof RegExp) {
633
- const m = scr.match(matcher);
634
- return m ?? null;
635
- }
636
- return matcher(scr, output);
694
+ let observed: { castTimeSec: number; screenPreview: string; screenTruncated: boolean } | undefined;
695
+ let outcome: TerminalWaitOutcome = "predicate_error";
696
+ const control = new AbortController();
697
+ const cancel = () => {
698
+ outcome = "cancelled";
699
+ control.abort(waitOpts?.signal?.reason ?? new Error(`waitFor ${JSON.stringify(description)} cancelled: terminal closed`));
637
700
  };
638
- while (true) {
639
- const v = check();
640
- if (v) {
641
- const seq = recordOpEvent("waitFor", { description, attempts, matched: true }, t, undefined, resv);
642
- // Tag the matched value so an `expect(...)` on it links its
643
- // assertion back to this waitFor step (sourceSeq) instead of
644
- // rendering as an orphan sidebar row. Mirrors how http/db/exec
645
- // results carry their op's seq. `as never` lets this single
646
- // body satisfy the three overload return types.
647
- return wrap(v, seq) as never;
648
- }
649
- if (Date.now() >= deadline) {
650
- const err = `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`;
651
- recordOpEvent(
652
- "waitFor",
653
- { description, attempts, matched: false },
654
- t,
655
- err,
656
- resv,
657
- );
658
- throw new Error(err);
701
+ const timeout = setTimeout(() => {
702
+ outcome = "timed_out";
703
+ control.abort(new Error(`waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`));
704
+ }, timeoutMs);
705
+ cancelWaits.add(cancel);
706
+ waitOpts?.signal?.addEventListener("abort", cancel, { once: true });
707
+ if (waitOpts?.signal?.aborted || closed) cancel();
708
+ const waitFields = () => ({
709
+ description, attempts, matched: outcome === "matched", outcome,
710
+ matcher: condition, failureMatchers, timeoutMs, intervalMs,
711
+ ...(outcome === "process_exited" ? { exitCode } : {}),
712
+ castStartTimeSec,
713
+ castEndTimeSec: castStartTimeSec + (Date.now() - t) / 1000,
714
+ });
715
+ const pause = () => new Promise<void>((resolve, reject) => {
716
+ const cleanup = () => {
717
+ clearTimeout(poll);
718
+ exitWaiters.delete(wake);
719
+ control.signal.removeEventListener("abort", abort);
720
+ };
721
+ const wake = () => { cleanup(); resolve(); };
722
+ const abort = () => { cleanup(); reject(control.signal.reason); };
723
+ const poll = setTimeout(wake, intervalMs);
724
+ exitWaiters.add(wake);
725
+ control.signal.addEventListener("abort", abort, { once: true });
726
+ if (control.signal.aborted) abort();
727
+ else if (exitSettled) wake();
728
+ });
729
+ try {
730
+ while (true) {
731
+ control.signal.throwIfAborted();
732
+ if (exited || exitSettled) {
733
+ try { await withSignal(rawExited, control.signal); }
734
+ catch (error) {
735
+ if (!control.signal.aborted) outcome = "process_exited";
736
+ throw error;
737
+ }
738
+ }
739
+ control.signal.throwIfAborted();
740
+ const checkedFinalScreen = exitSettled;
741
+ const screen = renderScreen();
742
+ const preview = truncateUtf8(screen);
743
+ observed = { castTimeSec: lastRenderedFrameT + 0.0005,
744
+ screenPreview: preview.value, screenTruncated: preview.truncated };
745
+ attempts++;
746
+ for (const failure of waitOpts?.failOn ?? []) {
747
+ if (matchScreen(screen, failure.matcher)) {
748
+ outcome = "failure_matched";
749
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: ${failure.description}`);
750
+ }
751
+ }
752
+ const observedOutput = output;
753
+ const value = typeof matcher === "function"
754
+ ? await withSignal(Promise.resolve().then(() => {
755
+ control.signal.throwIfAborted();
756
+ return matcher(screen, observedOutput);
757
+ }), control.signal)
758
+ : matchScreen(screen, matcher);
759
+ control.signal.throwIfAborted();
760
+ if (value) {
761
+ outcome = "matched";
762
+ const seq = recordOpEvent("waitFor", { ...waitFields(), ...observed }, t, undefined, resv);
763
+ return wrap(value, seq) as never;
764
+ }
765
+ if (exitSettled && !checkedFinalScreen) continue;
766
+ if (exitSettled) {
767
+ outcome = "process_exited";
768
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: terminal exited with code ${exitCode} before the condition matched`);
769
+ }
770
+ await pause();
659
771
  }
660
- await new Promise((r) => setTimeout(r, intervalMs));
772
+ } catch (error) {
773
+ recordOpEvent("waitFor", { ...waitFields(),
774
+ ...((outcome === "predicate_error" || outcome === "failure_matched") ? observed : {}),
775
+ }, t, errorMessage(error), resv);
776
+ throw error;
777
+ } finally {
778
+ clearTimeout(timeout);
779
+ cancelWaits.delete(cancel);
780
+ waitOpts?.signal?.removeEventListener("abort", cancel);
661
781
  }
662
782
  },
663
- async close() {
664
- // Use `rawExited` rather than `term.exited` everywhere we need a
665
- // raw number internally — the public getter resolves to a wrapped
666
- // result (and records an `exit` step on first read) which we don't
667
- // want as a side-effect of internal teardown logic.
668
- if (closed)
669
- return buildResult(
670
- exitCode ?? (await rawExited),
671
- ) as unknown as Wrapped<TerminalResult>;
672
- closed = true;
783
+ close(closeOpts?: TerminalCloseOptions) {
784
+ if (closePromise) return closePromise;
785
+ const timeoutMs = durationOption(closeOpts?.timeoutMs, 10_000, "timeoutMs");
673
786
  const t = Date.now();
674
787
  const resv = reserveEvent();
675
- // Polite first: send EOF (Ctrl-D). For a login shell this exits
676
- // cleanly. For a long-running command it's a no-op and we fall
677
- // through to SIGKILL.
678
- try {
679
- await writeStdin("\x04");
680
- } catch {
681
- /* ignore */
682
- }
683
- // Closing stdin signals EOF to the script wrapper too.
684
- try {
685
- (proc.stdin as { end?: () => void } | undefined)?.end?.();
686
- } catch {
687
- /* ignore */
688
- }
689
- // Grace period before forcibly killing.
690
- const grace = 1_500;
691
- const raced = await Promise.race([
692
- rawExited,
693
- new Promise<"timeout">((r) => setTimeout(() => r("timeout"), grace)),
694
- ]);
695
- if (raced === "timeout") {
788
+ closed = true;
789
+ for (const cancel of cancelWaits) cancel();
790
+ closePromise = (async () => {
696
791
  try {
697
- proc.kill();
698
- } catch {
699
- /* ignore */
792
+ if (!exited) {
793
+ // Send EOF even though new user operations are now closed.
794
+ try { await writeStdin("\x04", true); } catch { /* already exited */ }
795
+ try { (proc.stdin as { end?: () => void } | undefined)?.end?.(); } catch { /* already closed */ }
796
+ }
797
+ const grace = Math.min(1_500, timeoutMs / 2);
798
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
799
+ const finished = await Promise.race([
800
+ rawExited.then(() => true),
801
+ new Promise<false>((resolve) => { graceTimer = setTimeout(() => resolve(false), grace); }),
802
+ ]).finally(() => clearTimeout(graceTimer));
803
+ if (!finished && !exited) {
804
+ try { proc.kill("SIGKILL"); } catch { /* process already gone */ }
805
+ }
806
+ const code = await within(rawExited, Math.max(0, timeoutMs - (Date.now() - t)),
807
+ `Terminal close timed out after ${timeoutMs}ms`);
808
+ const result = buildResult(code);
809
+ const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
810
+ return wrap(result, seq) as unknown as Wrapped<TerminalResult>;
811
+ } catch (error) {
812
+ if (!exited) { try { proc.kill("SIGKILL"); } catch { /* best effort */ } }
813
+ stopOutput();
814
+ if (timer) clearTimeout(timer);
815
+ recordOpEvent("close", { exitCode }, t, errorMessage(error), resv);
816
+ throw error;
700
817
  }
701
- }
702
- const code = await rawExited;
703
- const result = buildResult(code);
704
- const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
705
- // Tag the result against the close step so assertions on the
706
- // transcript of a non-self-terminating session nest under it.
707
- return wrap(result, seq) as unknown as Wrapped<TerminalResult>;
818
+ })();
819
+ return closePromise;
708
820
  },
709
821
  };
710
822
 
711
823
  return handle;
712
824
  }
713
825
 
826
+ function errorMessage(error: unknown): string {
827
+ return error instanceof Error ? error.message : String(error);
828
+ }
829
+
830
+ function durationOption(value: number | undefined, fallback: number, name: string): number {
831
+ const duration = value ?? fallback;
832
+ if (!Number.isFinite(duration) || duration < 0) throw new Error(`${name} must be a finite non-negative number`);
833
+ return duration;
834
+ }
835
+
836
+ function describeMatcher(matcher: string | RegExp): TerminalWaitMatcher {
837
+ return typeof matcher === "string" ? { kind: "text", value: matcher }
838
+ : { kind: "regex", value: matcher.source, flags: matcher.flags };
839
+ }
840
+
841
+ function matchScreen(screen: string, matcher: string | RegExp) {
842
+ return typeof matcher === "string" ? (screen.includes(matcher) ? matcher : null)
843
+ : screen.match(new RegExp(matcher.source, matcher.flags));
844
+ }
845
+
846
+ function withSignal<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
847
+ return new Promise((resolve, reject) => {
848
+ const abort = () => reject(signal.reason);
849
+ signal.addEventListener("abort", abort, { once: true });
850
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
851
+ if (signal.aborted) {
852
+ signal.removeEventListener("abort", abort);
853
+ abort();
854
+ }
855
+ });
856
+ }
857
+
858
+ function within<T>(work: Promise<T>, ms: number, message: string): Promise<T> {
859
+ const control = new AbortController();
860
+ const timer = setTimeout(() => control.abort(new Error(message)), ms);
861
+ return withSignal(work, control.signal).finally(() => clearTimeout(timer));
862
+ }
863
+
714
864
  // ────────────────────────────────────────────────────────────────────────
715
865
  // Key name → byte sequence
716
866
  // ────────────────────────────────────────────────────────────────────────