@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/README.md +54 -0
- package/dist/browser.d.ts +12 -5
- package/dist/browser.js +13 -13
- package/dist/index.d.ts +2 -2
- package/dist/recorder.d.ts +29 -2
- package/dist/terminal.d.ts +31 -17
- package/dist/terminal.js +261 -105
- package/package.json +1 -1
- package/src/browser-navigation.test.ts +36 -0
- package/src/browser.ts +26 -18
- package/src/index.ts +2 -2
- package/src/recorder.ts +22 -2
- package/src/terminal.test.ts +324 -0
- package/src/terminal.ts +269 -119
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 -
|
|
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`.
|
|
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:
|
|
137
|
-
*
|
|
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?:
|
|
168
|
+
opts?: TerminalWaitOptions,
|
|
143
169
|
): Promise<Wrapped<string>>;
|
|
144
170
|
waitFor(
|
|
145
171
|
description: string,
|
|
146
172
|
matcher: RegExp,
|
|
147
|
-
opts?:
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
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)
|
|
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
|
-
|
|
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?:
|
|
680
|
+
waitOpts?: TerminalWaitOptions,
|
|
619
681
|
) {
|
|
620
|
-
const timeoutMs = waitOpts?.timeoutMs
|
|
621
|
-
const intervalMs = waitOpts?.intervalMs
|
|
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
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
// ────────────────────────────────────────────────────────────────────────
|