@specific.dev/spectest 0.128.0 → 0.130.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/s3-runtime/README.md +33 -0
- package/dist/components/s3-runtime/go.mod +11 -0
- package/dist/components/s3-runtime/go.sum +54 -0
- package/dist/components/s3-runtime/main.go +77 -0
- package/dist/components/s3-runtime/metadata.patch +12 -0
- package/dist/components/s3.d.ts +4 -5
- package/dist/components/s3.js +24 -14
- 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/components/s3-docker.test.ts +159 -0
- package/src/components/s3-runtime/README.md +33 -0
- package/src/components/s3-runtime/go.mod +11 -0
- package/src/components/s3-runtime/go.sum +54 -0
- package/src/components/s3-runtime/main.go +77 -0
- package/src/components/s3-runtime/metadata.patch +12 -0
- package/src/components/s3.test.ts +36 -0
- package/src/components/s3.ts +25 -16
- 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/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
|
// ────────────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { openBrowser } from "./browser.js";
|
|
3
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
4
|
+
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
|
|
6
|
+
|
|
7
|
+
test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
|
|
8
|
+
// The document commits immediately but load waits for an image. This
|
|
9
|
+
// distinguishes waitUntil behavior from simply extending a deadline.
|
|
10
|
+
const server = Bun.serve({port:0,hostname:"127.0.0.1", async fetch(request) {
|
|
11
|
+
if (new URL(request.url).pathname === "/slow.png") {
|
|
12
|
+
await Bun.sleep(500);
|
|
13
|
+
return new Response("", {status:404});
|
|
14
|
+
}
|
|
15
|
+
return new Response('<title>Navigation options</title><img src="/slow.png">',
|
|
16
|
+
{headers:{"content-type":"text/html","cache-control":"no-store"}});
|
|
17
|
+
}});
|
|
18
|
+
const browser = await openBrowser({recorder:{sessionId:"navigation",recordStep() {}}});
|
|
19
|
+
startRecording();
|
|
20
|
+
try {
|
|
21
|
+
await browser.goto(server.url.href, {waitUntil:"domcontentloaded",timeout:2000});
|
|
22
|
+
expect(await browser.title()).toBe("Navigation options");
|
|
23
|
+
await expect(browser.reload({waitUntil:"load",timeout:50})).rejects.toThrow(/Timeout/);
|
|
24
|
+
await browser.reload({waitUntil:"load",timeout:2000});
|
|
25
|
+
const events = stopRecording().filter(e => e.kind === "browser");
|
|
26
|
+
expect(events.filter(e => e.action === "goto" || e.action === "reload")).toMatchObject([
|
|
27
|
+
{action:"goto",waitUntil:"domcontentloaded",timeout:2000},
|
|
28
|
+
{action:"reload",waitUntil:"load",timeout:50,error:expect.any(String)},
|
|
29
|
+
{action:"reload",waitUntil:"load",timeout:2000},
|
|
30
|
+
]);
|
|
31
|
+
} finally {
|
|
32
|
+
stopRecording();
|
|
33
|
+
await browser.close();
|
|
34
|
+
server.stop(true);
|
|
35
|
+
}
|
|
36
|
+
}, 15000);
|
package/src/browser.ts
CHANGED
|
@@ -208,6 +208,12 @@ export interface Mouse {
|
|
|
208
208
|
wheel(dx: number, dy: number): Promise<void>;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
export interface NavigationOptions {
|
|
212
|
+
/** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
|
|
213
|
+
timeout?: number;
|
|
214
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
|
|
215
|
+
}
|
|
216
|
+
|
|
211
217
|
/**
|
|
212
218
|
* Headless browser session — Playwright `Page`-shaped. Select elements with
|
|
213
219
|
* the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
|
|
@@ -220,14 +226,14 @@ export interface Browser {
|
|
|
220
226
|
url(): string;
|
|
221
227
|
/** Current page `<title>` (async, like Playwright's `page.title()`). */
|
|
222
228
|
title(): Promise<string>;
|
|
223
|
-
/** Navigate to a URL;
|
|
224
|
-
goto(url: string): Promise<void>;
|
|
229
|
+
/** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
|
|
230
|
+
goto(url: string, options?: NavigationOptions): Promise<void>;
|
|
225
231
|
/** Navigate back in session history. */
|
|
226
|
-
goBack(): Promise<void>;
|
|
232
|
+
goBack(options?: NavigationOptions): Promise<void>;
|
|
227
233
|
/** Navigate forward in session history. */
|
|
228
|
-
goForward(): Promise<void>;
|
|
234
|
+
goForward(options?: NavigationOptions): Promise<void>;
|
|
229
235
|
/** Reload the current page. */
|
|
230
|
-
reload(): Promise<void>;
|
|
236
|
+
reload(options?: NavigationOptions): Promise<void>;
|
|
231
237
|
|
|
232
238
|
readonly keyboard: Keyboard;
|
|
233
239
|
readonly mouse: Mouse;
|
|
@@ -1995,11 +2001,11 @@ function buildBackend(
|
|
|
1995
2001
|
keyboard,
|
|
1996
2002
|
mouse,
|
|
1997
2003
|
touchscreen,
|
|
1998
|
-
goto(url) {
|
|
2004
|
+
goto(url, options) {
|
|
1999
2005
|
recorder?.noteNavigation?.(url);
|
|
2000
|
-
return instrumented("goto", { url }, async () => {
|
|
2006
|
+
return instrumented("goto", { url, ...options }, async () => {
|
|
2001
2007
|
try {
|
|
2002
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
2008
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
2003
2009
|
} catch (err) {
|
|
2004
2010
|
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
2005
2011
|
// view is persistent (so it may have lived through a snapshot
|
|
@@ -2009,23 +2015,23 @@ function buildBackend(
|
|
|
2009
2015
|
if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
|
|
2010
2016
|
if (!(await daemonResolves(url))) throw err;
|
|
2011
2017
|
await rebuildView(holder);
|
|
2012
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
2018
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
2013
2019
|
}
|
|
2014
2020
|
});
|
|
2015
2021
|
},
|
|
2016
|
-
goBack() {
|
|
2017
|
-
return instrumented("goBack", {}, async () => {
|
|
2018
|
-
await holder.page.goBack();
|
|
2022
|
+
goBack(options) {
|
|
2023
|
+
return instrumented("goBack", { ...options }, async () => {
|
|
2024
|
+
await holder.page.goBack(options);
|
|
2019
2025
|
});
|
|
2020
2026
|
},
|
|
2021
|
-
goForward() {
|
|
2022
|
-
return instrumented("goForward", {}, async () => {
|
|
2023
|
-
await holder.page.goForward();
|
|
2027
|
+
goForward(options) {
|
|
2028
|
+
return instrumented("goForward", { ...options }, async () => {
|
|
2029
|
+
await holder.page.goForward(options);
|
|
2024
2030
|
});
|
|
2025
2031
|
},
|
|
2026
|
-
reload() {
|
|
2027
|
-
return instrumented("reload", {}, async () => {
|
|
2028
|
-
await holder.page.reload();
|
|
2032
|
+
reload(options) {
|
|
2033
|
+
return instrumented("reload", { ...options }, async () => {
|
|
2034
|
+
await holder.page.reload(options);
|
|
2029
2035
|
});
|
|
2030
2036
|
},
|
|
2031
2037
|
|
|
@@ -2325,6 +2331,8 @@ function buildBackend(
|
|
|
2325
2331
|
}
|
|
2326
2332
|
|
|
2327
2333
|
export interface RecordableFields {
|
|
2334
|
+
timeout: number;
|
|
2335
|
+
waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
|
|
2328
2336
|
target: RecordedBrowserLocatorPart[];
|
|
2329
2337
|
destinationTarget: RecordedBrowserLocatorPart[];
|
|
2330
2338
|
url: string;
|