@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.
- package/README.md +54 -0
- package/dist/browser.d.ts +12 -5
- package/dist/browser.js +13 -13
- package/dist/components/k3s-coverage.d.ts +24 -0
- package/dist/components/k3s-coverage.js +259 -0
- package/dist/components/k3s-trust.d.ts +4 -2
- package/dist/components/k3s-trust.js +13 -0
- package/dist/components/k3s.js +31 -0
- package/dist/coverage/rust.rs +102 -0
- package/dist/coverage-bun.d.ts +10 -0
- package/dist/coverage-bun.js +32 -0
- package/dist/coverage-rust.d.ts +23 -0
- package/dist/coverage-rust.js +71 -0
- package/dist/coverage.d.ts +11 -5
- package/dist/coverage.js +70 -13
- package/dist/daemon.js +27 -2
- package/dist/harness/component-images.d.ts +1 -0
- package/dist/harness/component-images.js +4 -2
- package/dist/harness/coverage-target.d.ts +10 -0
- package/dist/harness/coverage-target.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +10 -1
- 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/components/k3s-coverage.test.ts +160 -0
- package/src/components/k3s-coverage.ts +234 -0
- package/src/components/k3s-trust.ts +14 -2
- package/src/components/k3s.ts +26 -0
- package/src/coverage/rust.rs +102 -0
- package/src/coverage-bun.test.ts +103 -0
- package/src/coverage-bun.ts +40 -0
- package/src/coverage-rust.test.ts +74 -0
- package/src/coverage-rust.ts +79 -0
- package/src/coverage.test.ts +4 -4
- package/src/coverage.ts +74 -13
- package/src/daemon.ts +22 -2
- package/src/harness/component-images.ts +5 -2
- package/src/harness/coverage-target.ts +55 -0
- package/src/index.ts +12 -3
- package/src/recorder.ts +22 -2
- package/src/terminal.test.ts +324 -0
- 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
|
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
|
575
|
+
/** Whether `waitFor` matched; see outcome for failure reasons. */
|
|
561
576
|
matched?: boolean;
|
|
562
|
-
/**
|
|
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;
|
package/dist/terminal.d.ts
CHANGED
|
@@ -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`.
|
|
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:
|
|
95
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
428
|
-
const intervalMs = waitOpts?.intervalMs
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ────────────────────────────────────────────────────────────────────────
|