@specific.dev/spectest 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,538 @@
1
+ // Interactive terminal handle for tests. Backed by `docker exec -it`
2
+ // inside a `script(1)` PTY wrapper so the container program sees a real
3
+ // TTY (line discipline, ANSI escapes, isatty(1) true) while the daemon
4
+ // drives stdin/stdout through ordinary pipes.
5
+ //
6
+ // We feed every byte the PTY emits into two sinks:
7
+ // 1. A headless `@xterm/headless` Terminal that maintains the rendered
8
+ // screen state (cursor, scrollback, alt-screen, attributes). This is
9
+ // what `waitFor`'s predicate evaluates against (and what the
10
+ // internal `rawScreen()` returns) — the analogue of evaluating
11
+ // against the DOM in a Browser session.
12
+ // 2. The asciicast frame callback the daemon hooks up to a
13
+ // TerminalSessionRecord. Same recording format as before; the web
14
+ // UI replays it with asciinema-player.
15
+ //
16
+ // The one-shot `ctx.terminal(service, command, opts?)` API in `index.ts`
17
+ // is built on top of this factory — it opens a Terminal, doesn't write
18
+ // to stdin, waits for the process to exit, and closes. So everything
19
+ // that flows through `ctx.terminal` flows through this code too.
20
+ //
21
+ // PTY-on-the-host trick: the daemon runs under systemd with no controlling
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
24
+ // a PTY for CMD; we set the slave's size with `stty rows R cols C` inside
25
+ // the wrapped command so the container app sees the cols/rows the caller
26
+ // asked for. `script(1)` ships in util-linux which is essential on Debian,
27
+ // so it's present in the Freestyle base image without extra apt deps.
28
+ import { Terminal as XtermHeadless } from "@xterm/headless";
29
+ import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
30
+ import { wrap } from "./inspect.js";
31
+ // Cap on cumulative output we keep in memory. The asciicast frames
32
+ // grow separately (they live on the session record) and aren't bounded
33
+ // here. Matches `TERMINAL_OUTPUT_CAP_BYTES` in daemon.ts.
34
+ const OUTPUT_CAP_BYTES = 1024 * 1024;
35
+ // Default scrollback for the headless emulator. Generous enough that
36
+ // long output histories aren't lost between `screen()` calls, small
37
+ // enough that the per-session memory cost stays bounded.
38
+ const SCROLLBACK = 10_000;
39
+ // Deadline for the TTY echo when recording a send/press step.
40
+ //
41
+ // Why we wait at all: `castTimeSec` is supposed to point at the cast
42
+ // frame the user wants to see when they click the step. For
43
+ // send/press, that's the frame containing the echo of what we typed.
44
+ // The echo is a PTY round-trip (our pipe → script → container TTY →
45
+ // kernel echo back → script → our pipe) that takes a few ms — at the
46
+ // instant `writeStdin` returns, no frame yet exists that contains the
47
+ // typed bytes. So we await the next ingested frame; that frame IS the
48
+ // echo. This isn't a fixed delay — it resolves as soon as the echo
49
+ // lands, typically <30ms.
50
+ //
51
+ // The timeout is a safety hatch only: if the program reads the input
52
+ // without ever echoing (rare — would require either raw mode + no app
53
+ // response, or a closed-fd condition), we don't want to hang the test.
54
+ // In that case `castTimeSec` falls back to the write moment, which is
55
+ // fine because there's no later echo frame for it to misalign with.
56
+ const ECHO_WAIT_MS = 120;
57
+ /**
58
+ * Open a PTY-backed interactive terminal in a service container.
59
+ *
60
+ * The factory itself doesn't know about `TerminalSessionRecord`s — it
61
+ * just calls `sink.pushFrame` for every chunk it sees and `markClosed`
62
+ * when the session ends. The daemon owns the record and the sink.
63
+ */
64
+ export async function openTerminal(args) {
65
+ const { service, opts, sink, sessionId } = args;
66
+ const cols = opts?.cols ?? 80;
67
+ const rows = opts?.rows ?? 24;
68
+ const userEnv = opts?.env ?? {};
69
+ const term = userEnv.TERM ?? "xterm-256color";
70
+ // Build the inner docker exec invocation. With `-it` the container
71
+ // sees a real TTY and dockerd merges stderr into stdout, so we only
72
+ // need to drain one stream from the PTY.
73
+ const dockerArgs = ["exec", "-it"];
74
+ dockerArgs.push("-e", `TERM=${term}`);
75
+ dockerArgs.push("-e", `COLUMNS=${cols}`);
76
+ dockerArgs.push("-e", `LINES=${rows}`);
77
+ for (const [k, v] of Object.entries(userEnv)) {
78
+ if (k === "TERM")
79
+ continue;
80
+ dockerArgs.push("-e", `${k}=${v}`);
81
+ }
82
+ dockerArgs.push(service);
83
+ if (opts?.command !== undefined) {
84
+ dockerArgs.push("sh", "-lc", opts.command);
85
+ }
86
+ else {
87
+ // Login shell — gives the user a $PS1 prompt to drive interactively.
88
+ dockerArgs.push("sh", "-l");
89
+ }
90
+ // Quote the inner docker command for `script -c`. We pass it as a
91
+ // single shell string. `stty` sets the PTY slave's size to the
92
+ // requested cols/rows before exec'ing docker — without this, the
93
+ // container app would see whatever size script's parent had (often
94
+ // 80x24, but the daemon has none, so it's whatever util-linux picks
95
+ // as the fallback).
96
+ const inner = `stty rows ${rows} cols ${cols} 2>/dev/null; exec ${shellQuote(["docker", ...dockerArgs])}`;
97
+ // `script -q -f -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
98
+ // after each write, output discarded — we capture via stdout pipe).
99
+ const spawnArgs = ["script", "-qfc", inner, "/dev/null"];
100
+ const start = Date.now();
101
+ const proc = Bun.spawn(spawnArgs, {
102
+ stdin: "pipe",
103
+ stdout: "pipe",
104
+ stderr: "pipe",
105
+ });
106
+ // Headless screen model. `allowProposedApi` lets us call `buffer.active`
107
+ // safely (it's marked EXPERIMENTAL in the public typings).
108
+ const emu = new XtermHeadless({
109
+ cols,
110
+ rows,
111
+ scrollback: SCROLLBACK,
112
+ allowProposedApi: true,
113
+ });
114
+ let output = "";
115
+ let exited = false;
116
+ let exitCode;
117
+ let closed = false;
118
+ let resolveExit;
119
+ const rawExited = new Promise((res) => {
120
+ resolveExit = res;
121
+ });
122
+ // Cast time at the moment the process actually exited. Captured by
123
+ // the IIFE below so the `exit` step we record points the player at
124
+ // the very end of the visible session.
125
+ let exitCastTimeSec;
126
+ const decoder = new TextDecoder("utf-8", { fatal: false });
127
+ // Subscribers waiting for the next frame to arrive. Used by send /
128
+ // sendLine / press so the recorded `castTimeSec` lands on the echo's
129
+ // frame instead of microseconds before it.
130
+ const frameWaiters = [];
131
+ const ingest = (chunk) => {
132
+ const t = (Date.now() - start) / 1000;
133
+ sink.pushFrame(t, chunk);
134
+ emu.write(chunk);
135
+ if (output.length < OUTPUT_CAP_BYTES) {
136
+ const room = OUTPUT_CAP_BYTES - output.length;
137
+ output += chunk.length > room ? chunk.slice(0, room) : chunk;
138
+ }
139
+ if (frameWaiters.length > 0) {
140
+ const fns = frameWaiters.splice(0, frameWaiters.length);
141
+ for (const fn of fns)
142
+ fn();
143
+ }
144
+ };
145
+ /**
146
+ * Resolve when the next PTY frame is ingested, or after `timeoutMs`
147
+ * elapses — whichever comes first. Used to delay a send/press
148
+ * recording just long enough for the TTY echo to land on a frame, so
149
+ * the seek target lines up with the visible state of the screen.
150
+ */
151
+ const waitForNextFrame = (timeoutMs) => {
152
+ return new Promise((resolve) => {
153
+ let done = false;
154
+ const finish = () => {
155
+ if (done)
156
+ return;
157
+ done = true;
158
+ resolve();
159
+ };
160
+ frameWaiters.push(finish);
161
+ setTimeout(finish, timeoutMs);
162
+ });
163
+ };
164
+ const drainStream = async (stream) => {
165
+ if (!stream)
166
+ return;
167
+ const reader = stream.getReader();
168
+ try {
169
+ while (true) {
170
+ const { done, value } = await reader.read();
171
+ if (done)
172
+ return;
173
+ if (!value || value.byteLength === 0)
174
+ continue;
175
+ const chunk = decoder.decode(value, { stream: true });
176
+ if (chunk.length === 0)
177
+ continue;
178
+ ingest(chunk);
179
+ }
180
+ }
181
+ finally {
182
+ reader.releaseLock();
183
+ }
184
+ };
185
+ // Drain stdout + stderr concurrently. `script` writes everything to
186
+ // stdout under `-t`, but we drain stderr anyway in case script(1)
187
+ // itself emits diagnostics there.
188
+ const stdout = proc.stdout;
189
+ const stderr = proc.stderr;
190
+ const drained = Promise.all([drainStream(stdout), drainStream(stderr)]);
191
+ // Race the optional session-level timeout. For interactive terminals
192
+ // we usually have no timeout; the wrapper for one-shot supplies one.
193
+ let timer;
194
+ if (opts?.timeoutMs && opts.timeoutMs > 0) {
195
+ timer = setTimeout(() => {
196
+ if (!exited) {
197
+ try {
198
+ proc.kill();
199
+ }
200
+ catch {
201
+ /* ignore */
202
+ }
203
+ }
204
+ }, opts.timeoutMs);
205
+ }
206
+ // When the process exits, drain whatever's left and resolve `exited`.
207
+ // We don't await `drained` here — it's already running and will
208
+ // settle on its own; the explicit `await drained` happens in close().
209
+ (async () => {
210
+ const code = await proc.exited;
211
+ exited = true;
212
+ exitCode = code;
213
+ if (timer)
214
+ clearTimeout(timer);
215
+ // Let `drained` finish so the final asciicast frames land before we
216
+ // mark the sink closed. drainStream is bounded by the OS pipe being
217
+ // closed when proc exits, so this won't hang.
218
+ try {
219
+ await drained;
220
+ }
221
+ catch {
222
+ /* ignore */
223
+ }
224
+ // Record cast time AFTER drain, so the `exit` step we record below
225
+ // points the player at the very last frame (the program's final
226
+ // output before EOF).
227
+ exitCastTimeSec = (Date.now() - start) / 1000;
228
+ sink.markClosed();
229
+ resolveExit(code);
230
+ })();
231
+ // Helper to write raw bytes to the PTY. We treat stdin as binary
232
+ // pipes (Bun's `proc.stdin` is a Web `WritableStream<Uint8Array>` or
233
+ // a FileSink-like depending on the version — use the FileSink-style
234
+ // `write(string)` which both shapes accept).
235
+ const stdinWriter = proc.stdin;
236
+ const writeStdin = async (data) => {
237
+ if (exited || closed || !stdinWriter)
238
+ return;
239
+ stdinWriter.write(data);
240
+ try {
241
+ const r = stdinWriter.flush?.();
242
+ // FileSink.flush returns void | number; ignore the return.
243
+ void r;
244
+ }
245
+ catch {
246
+ /* ignore */
247
+ }
248
+ };
249
+ const renderScreen = () => {
250
+ const buf = emu.buffer.active;
251
+ const lines = [];
252
+ // We render only the visible viewport (cursor-position-aware): the
253
+ // typical mental model of "what does the user see right now".
254
+ const viewportTop = buf.viewportY;
255
+ for (let y = 0; y < emu.rows; y++) {
256
+ const line = buf.getLine(viewportTop + y);
257
+ lines.push(line ? line.translateToString(true) : "");
258
+ }
259
+ // Drop trailing empty lines so multi-line predicates aren't padded.
260
+ while (lines.length > 1 && lines[lines.length - 1] === "")
261
+ lines.pop();
262
+ return lines.join("\n");
263
+ };
264
+ const recordOpEvent = (action, fields, startedAt, error, reservation) => {
265
+ if (!args.recordEvents)
266
+ return undefined;
267
+ const preview = truncateUtf8(renderScreen());
268
+ // Use the SAME clock the asciicast frames use (offset from `start`,
269
+ // which is captured at Bun.spawn). That way the UI seeks the player
270
+ // directly to this value with no frame arithmetic.
271
+ const castTimeSec = (Date.now() - start) / 1000;
272
+ // Return the event seq so callers can `wrap()` their result value
273
+ // against it — e.g. `expect(await term.waitFor(...))` then links its
274
+ // assertion to this step (sourceSeq) and the UI nests it here.
275
+ return recordTerminalStep({
276
+ sessionId,
277
+ service,
278
+ action,
279
+ castTimeSec,
280
+ durationMs: Date.now() - startedAt,
281
+ screenPreview: preview.value,
282
+ screenTruncated: preview.truncated,
283
+ error,
284
+ ...fields,
285
+ }, reservation);
286
+ };
287
+ // Assemble the public result shape (mirrors the one-shot
288
+ // `ctx.terminal(...)` TerminalResult) from current capture state.
289
+ const buildResult = (code) => ({
290
+ output,
291
+ exitCode: code,
292
+ durationMs: Date.now() - start,
293
+ sessionId,
294
+ });
295
+ // Memoised promise behind `term.exited`. Lazily constructed on first
296
+ // read so the `exit` step is only recorded if the test actually
297
+ // awaits exit — the one-shot wrapper internally uses `rawExited`
298
+ // directly and so skips the step (which is what we want; the one-shot
299
+ // already emits a `terminal` event covering the whole lifecycle).
300
+ let exitedAccessor;
301
+ const getExited = () => {
302
+ if (exitedAccessor)
303
+ return exitedAccessor;
304
+ const resv = reserveEvent();
305
+ exitedAccessor = (async () => {
306
+ const code = await rawExited;
307
+ const result = buildResult(code);
308
+ // No recorder (eval/setup): there's no event to nest under, but we
309
+ // still wrap (with an undefined seq) so the accessor's wrapped type is
310
+ // honest at runtime — same as the one-shot `ctx.terminal`/`fetch` paths.
311
+ if (!args.recordEvents) {
312
+ return wrap(result, undefined);
313
+ }
314
+ const preview = truncateUtf8(renderScreen());
315
+ const seq = recordTerminalStep({
316
+ sessionId,
317
+ service,
318
+ action: "exit",
319
+ castTimeSec: exitCastTimeSec ?? (Date.now() - start) / 1000,
320
+ durationMs: 0,
321
+ screenPreview: preview.value,
322
+ screenTruncated: preview.truncated,
323
+ exitCode: code,
324
+ }, resv);
325
+ // Tag the whole result against the exit step so assertions on
326
+ // `.output` / `.exitCode` nest under the terminal in the timeline.
327
+ return wrap(result, seq);
328
+ })();
329
+ return exitedAccessor;
330
+ };
331
+ const handle = {
332
+ cols,
333
+ rows,
334
+ sessionId,
335
+ get exited() {
336
+ return getExited();
337
+ },
338
+ get exitCode() {
339
+ return exitCode;
340
+ },
341
+ async send(input) {
342
+ const t = Date.now();
343
+ const resv = reserveEvent();
344
+ const trunc = truncateUtf8(input);
345
+ try {
346
+ await writeStdin(input);
347
+ // Block until the echo of what we just wrote lands on a PTY
348
+ // frame. `writeStdin` returns the instant the bytes are in
349
+ // the pipe buffer; the kernel TTY echo takes a few ms to
350
+ // round-trip back through script + docker exec + our stdout
351
+ // pipe. Recording `castTimeSec` before that frame arrives
352
+ // would point the step's seek target at a screen state that
353
+ // doesn't yet show what was typed. The await resolves the
354
+ // moment that next frame is ingested — it's the mechanism,
355
+ // not a delay.
356
+ await waitForNextFrame(ECHO_WAIT_MS);
357
+ recordOpEvent("send", { text: trunc.value, textTruncated: trunc.truncated }, t, undefined, resv);
358
+ }
359
+ catch (err) {
360
+ const e = err;
361
+ recordOpEvent("send", { text: trunc.value, textTruncated: trunc.truncated }, t, e?.message ?? String(err), resv);
362
+ throw err;
363
+ }
364
+ },
365
+ async sendLine(line) {
366
+ const t = Date.now();
367
+ const resv = reserveEvent();
368
+ const trunc = truncateUtf8(line);
369
+ try {
370
+ await writeStdin(line + "\n");
371
+ await waitForNextFrame(ECHO_WAIT_MS);
372
+ recordOpEvent("sendLine", { text: trunc.value, textTruncated: trunc.truncated }, t, undefined, resv);
373
+ }
374
+ catch (err) {
375
+ const e = err;
376
+ recordOpEvent("sendLine", { text: trunc.value, textTruncated: trunc.truncated }, t, e?.message ?? String(err), resv);
377
+ throw err;
378
+ }
379
+ },
380
+ async press(key) {
381
+ const t = Date.now();
382
+ const resv = reserveEvent();
383
+ const bytes = keyToBytes(key);
384
+ try {
385
+ await writeStdin(bytes);
386
+ await waitForNextFrame(ECHO_WAIT_MS);
387
+ recordOpEvent("press", { key }, t, undefined, resv);
388
+ }
389
+ catch (err) {
390
+ const e = err;
391
+ recordOpEvent("press", { key }, t, e?.message ?? String(err), resv);
392
+ throw err;
393
+ }
394
+ },
395
+ rawOutput() {
396
+ return output;
397
+ },
398
+ rawScreen() {
399
+ return renderScreen();
400
+ },
401
+ async waitFor(description, matcher, waitOpts) {
402
+ const timeoutMs = waitOpts?.timeoutMs ?? 5_000;
403
+ const intervalMs = waitOpts?.intervalMs ?? 100;
404
+ const t = Date.now();
405
+ const resv = reserveEvent();
406
+ const deadline = t + timeoutMs;
407
+ let attempts = 0;
408
+ const check = () => {
409
+ attempts++;
410
+ const scr = renderScreen();
411
+ if (typeof matcher === "string") {
412
+ return scr.includes(matcher) ? matcher : null;
413
+ }
414
+ if (matcher instanceof RegExp) {
415
+ const m = scr.match(matcher);
416
+ return m ?? null;
417
+ }
418
+ return matcher(scr, output);
419
+ };
420
+ while (true) {
421
+ const v = check();
422
+ if (v) {
423
+ const seq = recordOpEvent("waitFor", { description, attempts, matched: true }, t, undefined, resv);
424
+ // Tag the matched value so an `expect(...)` on it links its
425
+ // assertion back to this waitFor step (sourceSeq) instead of
426
+ // rendering as an orphan sidebar row. Mirrors how http/db/exec
427
+ // results carry their op's seq. `as never` lets this single
428
+ // body satisfy the three overload return types.
429
+ return wrap(v, seq);
430
+ }
431
+ if (Date.now() >= deadline) {
432
+ const err = `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`;
433
+ recordOpEvent("waitFor", { description, attempts, matched: false }, t, err, resv);
434
+ throw new Error(err);
435
+ }
436
+ await new Promise((r) => setTimeout(r, intervalMs));
437
+ }
438
+ },
439
+ async close() {
440
+ // Use `rawExited` rather than `term.exited` everywhere we need a
441
+ // raw number internally — the public getter resolves to a wrapped
442
+ // result (and records an `exit` step on first read) which we don't
443
+ // want as a side-effect of internal teardown logic.
444
+ if (closed)
445
+ return buildResult(exitCode ?? (await rawExited));
446
+ closed = true;
447
+ const t = Date.now();
448
+ const resv = reserveEvent();
449
+ // Polite first: send EOF (Ctrl-D). For a login shell this exits
450
+ // cleanly. For a long-running command it's a no-op and we fall
451
+ // through to SIGKILL.
452
+ try {
453
+ await writeStdin("\x04");
454
+ }
455
+ catch {
456
+ /* ignore */
457
+ }
458
+ // Closing stdin signals EOF to the script wrapper too.
459
+ try {
460
+ proc.stdin?.end?.();
461
+ }
462
+ catch {
463
+ /* ignore */
464
+ }
465
+ // Grace period before forcibly killing.
466
+ const grace = 1_500;
467
+ const raced = await Promise.race([
468
+ rawExited,
469
+ new Promise((r) => setTimeout(() => r("timeout"), grace)),
470
+ ]);
471
+ if (raced === "timeout") {
472
+ try {
473
+ proc.kill();
474
+ }
475
+ catch {
476
+ /* ignore */
477
+ }
478
+ }
479
+ const code = await rawExited;
480
+ const result = buildResult(code);
481
+ const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
482
+ // Tag the result against the close step so assertions on the
483
+ // transcript of a non-self-terminating session nest under it.
484
+ return wrap(result, seq);
485
+ },
486
+ };
487
+ return handle;
488
+ }
489
+ // ────────────────────────────────────────────────────────────────────────
490
+ // Key name → byte sequence
491
+ // ────────────────────────────────────────────────────────────────────────
492
+ const KEY_MAP = {
493
+ enter: "\r",
494
+ return: "\r",
495
+ tab: "\t",
496
+ backspace: "\x7f",
497
+ delete: "\x1b[3~",
498
+ escape: "\x1b",
499
+ esc: "\x1b",
500
+ up: "\x1b[A",
501
+ down: "\x1b[B",
502
+ right: "\x1b[C",
503
+ left: "\x1b[D",
504
+ home: "\x1b[H",
505
+ end: "\x1b[F",
506
+ pageup: "\x1b[5~",
507
+ pagedown: "\x1b[6~",
508
+ space: " ",
509
+ };
510
+ function keyToBytes(key) {
511
+ const norm = key.replace(/[\s_-]/g, "").toLowerCase();
512
+ if (KEY_MAP[norm] !== undefined)
513
+ return KEY_MAP[norm];
514
+ // Ctrl+<letter>: ASCII control char 1-26.
515
+ const ctrl = key.match(/^ctrl[\s_+-]+([a-z])$/i);
516
+ if (ctrl) {
517
+ const letter = ctrl[1].toLowerCase();
518
+ const code = letter.charCodeAt(0) - "a".charCodeAt(0) + 1;
519
+ return String.fromCharCode(code);
520
+ }
521
+ // Fall back to the literal string — lets callers type a single
522
+ // character via `press("X")` even if it's not in the table.
523
+ return key;
524
+ }
525
+ // ────────────────────────────────────────────────────────────────────────
526
+ // Shell quoting for `script -c CMD`
527
+ // ────────────────────────────────────────────────────────────────────────
528
+ function shellQuote(argv) {
529
+ return argv
530
+ .map((a) => {
531
+ if (a.length === 0)
532
+ return "''";
533
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a))
534
+ return a;
535
+ return `'${a.replace(/'/g, `'\\''`)}'`;
536
+ })
537
+ .join(" ");
538
+ }
package/package.json CHANGED
@@ -1,19 +1,34 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts",
10
- "./components": "./src/components/index.ts",
11
- "./browser": "./src/browser.ts",
12
- "./daemon": "./src/daemon.ts",
13
- "./resolver": "./src/resolver.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./components": {
14
+ "types": "./dist/components/index.d.ts",
15
+ "default": "./dist/components/index.js"
16
+ },
17
+ "./browser": {
18
+ "types": "./dist/browser.d.ts",
19
+ "default": "./dist/browser.js"
20
+ },
21
+ "./daemon": {
22
+ "types": "./dist/daemon.d.ts",
23
+ "default": "./dist/daemon.js"
24
+ },
25
+ "./resolver": {
26
+ "types": "./dist/resolver.d.ts",
27
+ "default": "./dist/resolver.js"
28
+ }
14
29
  },
15
30
  "files": [
16
- "src",
31
+ "dist",
17
32
  "README.md"
18
33
  ],
19
34
  "repository": {