@specific.dev/spectest 0.39.0 → 0.41.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 (100) hide show
  1. package/dist/components/supabase.d.ts +87 -27
  2. package/dist/components/supabase.js +352 -69
  3. package/dist/daemon.d.ts +38 -0
  4. package/dist/daemon.js +388 -941
  5. package/dist/harness/build-context.d.ts +82 -0
  6. package/dist/harness/build-context.js +113 -0
  7. package/dist/harness/buildkit-progress.d.ts +37 -0
  8. package/dist/harness/buildkit-progress.js +66 -0
  9. package/dist/harness/container-run.d.ts +89 -0
  10. package/dist/harness/container-run.js +118 -0
  11. package/dist/harness/file-mounts.d.ts +91 -0
  12. package/dist/harness/file-mounts.js +119 -0
  13. package/dist/harness/hostmatch.d.ts +65 -0
  14. package/dist/harness/hostmatch.js +108 -0
  15. package/dist/harness/http-proxy.d.ts +62 -0
  16. package/dist/harness/http-proxy.js +104 -0
  17. package/dist/harness/ingress-table.d.ts +148 -0
  18. package/dist/harness/ingress-table.js +129 -0
  19. package/dist/harness/log-delta.d.ts +54 -0
  20. package/dist/harness/log-delta.js +83 -0
  21. package/dist/harness/main.d.ts +47 -0
  22. package/dist/harness/main.js +164 -0
  23. package/dist/harness/methods.d.ts +54 -0
  24. package/dist/harness/methods.js +65 -0
  25. package/dist/harness/names-registry.d.ts +63 -0
  26. package/dist/harness/names-registry.js +90 -0
  27. package/dist/harness/protocol.d.ts +88 -0
  28. package/dist/harness/protocol.js +96 -0
  29. package/dist/harness/ready-poll.d.ts +47 -0
  30. package/dist/harness/ready-poll.js +67 -0
  31. package/dist/harness/service-graph.d.ts +29 -0
  32. package/dist/harness/service-graph.js +92 -0
  33. package/dist/harness/volume-paths.d.ts +70 -0
  34. package/dist/harness/volume-paths.js +81 -0
  35. package/dist/index.d.ts +3 -3
  36. package/dist/ingress.d.ts +1 -1
  37. package/dist/resolver.js +5 -8
  38. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  39. package/dist/vendor/rrweb-record.min.js +5061 -0
  40. package/package.json +7 -1
  41. package/src/aws-sigv4.ts +218 -0
  42. package/src/browser.ts +2040 -0
  43. package/src/components/aws.ts +554 -0
  44. package/src/components/email.ts +398 -0
  45. package/src/components/expo.ts +167 -0
  46. package/src/components/index.ts +81 -0
  47. package/src/components/k3s.ts +2061 -0
  48. package/src/components/postgres.ts +132 -0
  49. package/src/components/replayFake.ts +1015 -0
  50. package/src/components/s3.ts +132 -0
  51. package/src/components/supabase.ts +1699 -0
  52. package/src/daemon.ts +5489 -0
  53. package/src/harness/build-context.test.ts +0 -0
  54. package/src/harness/build-context.ts +146 -0
  55. package/src/harness/buildkit-progress.test.ts +98 -0
  56. package/src/harness/buildkit-progress.ts +74 -0
  57. package/src/harness/container-run.test.ts +209 -0
  58. package/src/harness/container-run.ts +158 -0
  59. package/src/harness/file-mounts.test.ts +185 -0
  60. package/src/harness/file-mounts.ts +145 -0
  61. package/src/harness/hostmatch.test.ts +148 -0
  62. package/src/harness/hostmatch.ts +109 -0
  63. package/src/harness/http-proxy.test.ts +156 -0
  64. package/src/harness/http-proxy.ts +119 -0
  65. package/src/harness/ingress-rebind.test.ts +125 -0
  66. package/src/harness/ingress-table.test.ts +172 -0
  67. package/src/harness/ingress-table.ts +186 -0
  68. package/src/harness/log-delta.test.ts +125 -0
  69. package/src/harness/log-delta.ts +100 -0
  70. package/src/harness/main.test.ts +211 -0
  71. package/src/harness/main.ts +196 -0
  72. package/src/harness/methods.test.ts +63 -0
  73. package/src/harness/methods.ts +92 -0
  74. package/src/harness/names-registry.test.ts +137 -0
  75. package/src/harness/names-registry.ts +108 -0
  76. package/src/harness/protocol.test.ts +148 -0
  77. package/src/harness/protocol.ts +163 -0
  78. package/src/harness/ready-poll.test.ts +172 -0
  79. package/src/harness/ready-poll.ts +93 -0
  80. package/src/harness/service-graph.test.ts +97 -0
  81. package/src/harness/service-graph.ts +97 -0
  82. package/src/harness/volume-paths.test.ts +102 -0
  83. package/src/harness/volume-paths.ts +112 -0
  84. package/src/ids.ts +89 -0
  85. package/src/index.ts +2725 -0
  86. package/src/ingress.ts +305 -0
  87. package/src/inspect.ts +739 -0
  88. package/src/locator.ts +716 -0
  89. package/src/mobile.ts +133 -0
  90. package/src/record-secrets.ts +41 -0
  91. package/src/recorder.ts +846 -0
  92. package/src/redis.ts +202 -0
  93. package/src/replay-bundle.ts +108 -0
  94. package/src/resolver.ts +348 -0
  95. package/src/s3.ts +333 -0
  96. package/src/sql.ts +243 -0
  97. package/src/terminal.ts +740 -0
  98. package/src/url-match.ts +67 -0
  99. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  100. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,846 @@
1
+ // Per-test event recorder. The daemon installs a fresh recorder before
2
+ // running a test case and drains it afterwards. While a recorder is
3
+ // active, instrumented sites (the SDK's `expect`, the daemon's wrapped
4
+ // `ctx.exec`, and the test-scoped `fetch` wrapper) push structured
5
+ // events into it.
6
+ //
7
+ // When no test is running (production code paths, eval, etc.) the
8
+ // current recorder is `null` and the `record*` helpers are no-ops, so
9
+ // callers can invoke them unconditionally.
10
+
11
+ import { clearPendingNullish } from "./inspect.js";
12
+
13
+ const OUTPUT_SNIPPET_BYTES = 256 * 1024;
14
+
15
+ export type TestEvent =
16
+ | ExecEvent
17
+ | AssertionEvent
18
+ | HttpEvent
19
+ | KubeEvent
20
+ | BrowserEvent
21
+ | DbEvent
22
+ | RedisEvent
23
+ | S3Event
24
+ | TerminalEvent
25
+ | TerminalStepEvent
26
+ | WaitEvent
27
+ | FakeEvent
28
+ | EnvEvent
29
+ | EmailEvent;
30
+
31
+ interface BaseEvent {
32
+ /** Order of *start* within the test. Reserved when an op begins (see
33
+ * `reserveEvent`), so an op whose nested children finish — and record —
34
+ * before it does still sorts ahead of them. Ops that don't reserve get
35
+ * their seq at record (= finish) time. */
36
+ seq: number;
37
+ /** Milliseconds since the recorder started, captured when the op *began* —
38
+ * either reserved up front, or backdated at record time by an op that could
39
+ * only reserve at finish (`reserveBackdated`). Consumers read the step's
40
+ * completion time as `tOffsetMs + durationMs`, so an op that stamps a bare
41
+ * finish offset while also reporting a duration will render late. */
42
+ tOffsetMs: number;
43
+ /**
44
+ * Optional grouping pointer. When set, this event was emitted inside
45
+ * a larger logical step (e.g. an HTTP call that fed the predicate of
46
+ * a `ctx.poll(...)`) and should render nested under the parent in
47
+ * timelines. The parent event is identified by its `seq`.
48
+ */
49
+ parentSeq?: number;
50
+ }
51
+
52
+ export interface ExecEvent extends BaseEvent {
53
+ kind: "exec";
54
+ service: string;
55
+ command: string;
56
+ /**
57
+ * Working directory the command ran from (`ctx.exec(svc, cmd, { cwd })`),
58
+ * if any. Kept off `command` so the sidebar shows just the command; the
59
+ * detail view surfaces it. Absent when the call used no `cwd`.
60
+ */
61
+ cwd?: string;
62
+ /**
63
+ * The payload piped to the command's stdin (`ctx.exec(svc, cmd, {
64
+ * stdin })`), truncated to OUTPUT_SNIPPET_BYTES. Recorded because the
65
+ * asciicast only captures what the command *wrote* — without this, a
66
+ * `kubectl apply -f -` step shows its error but not the manifest that
67
+ * caused it. Absent when the call piped nothing.
68
+ */
69
+ stdin?: string;
70
+ stdinTruncated?: boolean;
71
+ exitCode: number;
72
+ /** stdout truncated to OUTPUT_SNIPPET_BYTES. */
73
+ stdout: string;
74
+ stdoutTruncated: boolean;
75
+ stderr: string;
76
+ stderrTruncated: boolean;
77
+ durationMs: number;
78
+ /**
79
+ * Links this exec to the asciicast `TerminalSessionRecord` of its run
80
+ * (mirroring `TerminalEvent.sessionId`): one exec step covers one full
81
+ * CLI run, so the whole recording belongs to this event. Absent on
82
+ * events recorded before exec runs were captured.
83
+ */
84
+ sessionId?: string;
85
+ }
86
+
87
+ export interface AssertionEvent extends BaseEvent {
88
+ kind: "assertion";
89
+ /** Matcher name, e.g. "toBe", "toEqual". */
90
+ matcher: string;
91
+ /** Whether this was `expect(x).not.toBe(...)`. */
92
+ negated: boolean;
93
+ passed: boolean;
94
+ /** Best-effort JSON-safe serialization. */
95
+ actual: unknown;
96
+ expected?: unknown;
97
+ /** Failure message produced by the matcher (only when `passed` is false). */
98
+ error?: string;
99
+ /**
100
+ * Author-supplied label for the assertion. **Required** for a raw assertion
101
+ * (`expectRaw(value, message)`), where it renders as the summary
102
+ * ("ASSERT <message>") and serves as the diff-alignment key since a raw
103
+ * assertion has no provenance `path`. **Optional** for a provenance-linked
104
+ * `expect(value, message)`, where it leads the summary as a clarifying note
105
+ * alongside the rendered matcher/target. Absent when `expect(...)` was called
106
+ * without a message.
107
+ */
108
+ message?: string;
109
+ /**
110
+ * `seq` of the op (http / db / browser) whose return value this
111
+ * assertion drilled into. Set when `expect()` received a value wrapped
112
+ * by `inspect.wrap()`. The UI nests assertions with `sourceSeq` under
113
+ * the matching op; assertions without it render at top level.
114
+ */
115
+ sourceSeq?: number;
116
+ /** JSON-path of property accesses from the op return value down to
117
+ * the asserted value (e.g. `["body", "user", "name"]`). Empty for
118
+ * direct asserts on the op return itself. */
119
+ path?: string[];
120
+ }
121
+
122
+ export interface DbEvent extends BaseEvent {
123
+ kind: "db";
124
+ /** Service the query ran against (the key in `environment.services`). */
125
+ service: string;
126
+ /** SQL text. For tagged-template calls, values appear as `$1`, `$2`, … */
127
+ query: string;
128
+ /** Parameter values. Best-effort JSON-safe; large/binary values stringified. */
129
+ params?: unknown[];
130
+ /** Rows returned — or rows AFFECTED when `rowsAffected` is set. */
131
+ rowCount?: number;
132
+ /** Set when `rowCount` counts affected rows (a non-RETURNING
133
+ * INSERT/UPDATE/DELETE) rather than a result set — rendered as
134
+ * "N affected" so an `UPDATE → 0 rows` can't read as "nothing updated". */
135
+ rowsAffected?: boolean;
136
+ /** Captured rows for table rendering in the web UI. Capped at
137
+ * MAX_DB_ROWS; `rowsTruncated` is set when there were more. */
138
+ rows?: unknown[];
139
+ rowsTruncated?: boolean;
140
+ /** Column names in the order Bun.SQL returned them. Derived from the
141
+ * first captured row, so omitted when no rows. */
142
+ columns?: string[];
143
+ durationMs: number;
144
+ /** Set if the driver threw. */
145
+ error?: string;
146
+ }
147
+
148
+ /**
149
+ * One Redis/Valkey command issued through the instrumented {@link
150
+ * "../redis".RedisClient} (a drop-in for `Bun.RedisClient` / `Bun.redis`).
151
+ *
152
+ * The return value is `wrap()`ped against this event's seq, so a later
153
+ * `expect(...)` that drills into it nests under this step in the timeline
154
+ * (same mechanism as http/db).
155
+ */
156
+ export interface RedisEvent extends BaseEvent {
157
+ kind: "redis";
158
+ /** Label for the connection — the URL host (often the service name). */
159
+ service: string;
160
+ /** Command name, upper-cased (`GET`, `SET`, `HGETALL`, `PUBLISH`, …). */
161
+ command: string;
162
+ /** First argument — almost always the key the command targets. Surfaced
163
+ * separately so the UI can read `GET session:42` at a glance. */
164
+ key?: string;
165
+ /** Remaining arguments (best-effort JSON-safe; large/binary stringified).
166
+ * Omitted when there are none. */
167
+ args?: unknown[];
168
+ /** Reply, best-effort JSON-safe and capped. Omitted when the command threw
169
+ * or returned `null`/`undefined`. */
170
+ result?: unknown;
171
+ resultTruncated?: boolean;
172
+ durationMs: number;
173
+ /** Set if the command threw. */
174
+ error?: string;
175
+ }
176
+
177
+ /**
178
+ * One object-storage operation through the instrumented {@link
179
+ * "../s3".S3Client} (a drop-in for `Bun.S3Client` / `Bun.s3`), covering both
180
+ * client-level calls (`write`/`delete`/`exists`/`list`/`presign`/`stat`) and
181
+ * the `S3File` handle `.file(path)` returns (`text`/`json`/`write`/…).
182
+ *
183
+ * The return value is `wrap()`ped against this event's seq, so `expect(...)`
184
+ * drilling into it nests under this step (same mechanism as http/db).
185
+ */
186
+ export interface S3Event extends BaseEvent {
187
+ kind: "s3";
188
+ /** Operation: `write` | `read` | `delete` | `exists` | `list` |
189
+ * `presign` | `stat`. */
190
+ op: string;
191
+ /** Bucket, when the client/handle knows it. */
192
+ bucket?: string;
193
+ /** Object key (path) the op targeted. Omitted for bucket-wide `list`. */
194
+ key?: string;
195
+ /** Bytes written or read, when known. */
196
+ size?: number;
197
+ /** Content type, when known (read/write/stat). */
198
+ contentType?: string;
199
+ /** Small text preview of the body (read/write of text), capped. */
200
+ preview?: string;
201
+ previewTruncated?: boolean;
202
+ /** For `list`: number of keys returned. */
203
+ count?: number;
204
+ durationMs: number;
205
+ /** Set if the op threw. */
206
+ error?: string;
207
+ }
208
+
209
+ export interface HttpEvent extends BaseEvent {
210
+ kind: "http";
211
+ method: string;
212
+ url: string;
213
+ /** Truncated to OUTPUT_SNIPPET_BYTES. Only set for non-binary text bodies. */
214
+ requestBody?: string;
215
+ requestBodyTruncated?: boolean;
216
+ status?: number;
217
+ responseBody?: string;
218
+ responseBodyTruncated?: boolean;
219
+ durationMs: number;
220
+ /** Set if the request threw (network error, abort, etc.). */
221
+ error?: string;
222
+ }
223
+
224
+ /**
225
+ * A Kubernetes API call (from `ctx.svc.<k3s>.client.*` / `apply(...)`).
226
+ *
227
+ * The k3s component routes the kube client through `globalThis.fetch`, so
228
+ * the daemon's fetch wrapper first records it as a plain `http` event;
229
+ * the component then parses the request path and *reclassifies* that event
230
+ * into this richer shape via `recorderAnnotate`, so the UI can show
231
+ * `verb resource/name` (e.g. `list pods · default`) instead of an opaque
232
+ * `GET https://k8s.internal:6443/api/v1/...`. The underlying HTTP fields
233
+ * are retained so the request/response bodies (the actual K8s objects)
234
+ * still render in the detail view, and `expect(...)` on the returned
235
+ * object nests under this event exactly as it does for `http`.
236
+ */
237
+ export interface KubeEvent extends BaseEvent {
238
+ kind: "kube";
239
+ /** API verb derived from the HTTP method + path shape: `get` | `list` |
240
+ * `watch` | `create` | `update` | `patch` | `delete` |
241
+ * `deletecollection`. */
242
+ verb: string;
243
+ /** API group — `""` for the core group (`/api/v1`), else e.g. `"apps"`,
244
+ * `"networking.k8s.io"`. */
245
+ group?: string;
246
+ /** API version, e.g. `"v1"`. */
247
+ apiVersion?: string;
248
+ /** Resource plural, e.g. `"pods"`, `"deployments"`, `"ingresses"`. */
249
+ resource?: string;
250
+ /** Subresource, when addressed, e.g. `"status"`, `"scale"`, `"log"`. */
251
+ subresource?: string;
252
+ /** Object name, when the request targets a single object. */
253
+ name?: string;
254
+ /** Namespace, when namespaced. Absent for cluster-scoped requests. */
255
+ namespace?: string;
256
+ // Underlying HTTP fields, retained for drill-down (mirror HttpEvent).
257
+ method: string;
258
+ url: string;
259
+ requestBody?: string;
260
+ requestBodyTruncated?: boolean;
261
+ status?: number;
262
+ responseBody?: string;
263
+ responseBodyTruncated?: boolean;
264
+ durationMs: number;
265
+ error?: string;
266
+ }
267
+
268
+ /**
269
+ * One call to a fake's helper function (`ctx.fakes.<name>.<fn>(...)`).
270
+ * Helpers are user-authored functions that read or mutate the fake's
271
+ * private state, so this is the only window into what a test asked the
272
+ * fake — distinct from the `http` events the *app under test* generates
273
+ * when it actually calls the fake's endpoints.
274
+ *
275
+ * The return value is `wrap()`ped against this event's seq, so a later
276
+ * `expect(...)` that drills into it nests under this step in the
277
+ * timeline (same mechanism as http/db/exec).
278
+ */
279
+ export interface FakeEvent extends BaseEvent {
280
+ kind: "fake";
281
+ /** Fake name — the key in `ctx.fakes`. */
282
+ fake: string;
283
+ /** Helper function called, e.g. `"lastCharge"`. */
284
+ member: string;
285
+ /** Call arguments (best-effort JSON-safe). */
286
+ args?: unknown[];
287
+ /** Return value (best-effort JSON-safe). Omitted when the helper threw
288
+ * or returned `undefined`. */
289
+ result?: unknown;
290
+ durationMs: number;
291
+ /** Set if the helper threw. */
292
+ error?: string;
293
+ }
294
+
295
+ /**
296
+ * One captured email, as embedded on an {@link EmailEvent}. The HTML and
297
+ * text bodies ride along (truncated to the output cap) so the dashboard
298
+ * can render the actual email a test asserted against.
299
+ */
300
+ export interface EmailEventMessage {
301
+ /** Sender address. */
302
+ from?: string;
303
+ /** Recipient addresses. */
304
+ to?: string[];
305
+ cc?: string[];
306
+ bcc?: string[];
307
+ subject?: string;
308
+ /** RFC date of the message, ISO-formatted. */
309
+ date?: string;
310
+ /** HTML body (truncated to the output cap). */
311
+ html?: string;
312
+ htmlTruncated?: boolean;
313
+ /** Plain-text body (truncated to the output cap). */
314
+ text?: string;
315
+ textTruncated?: boolean;
316
+ attachments?: { filename: string; contentType: string; size: number }[];
317
+ }
318
+
319
+ /** One row of a mailbox listing embedded on an {@link EmailEvent}. */
320
+ export interface EmailEventSummary {
321
+ from?: string;
322
+ to?: string[];
323
+ subject?: string;
324
+ /** Plain-text preview of the body. */
325
+ snippet?: string;
326
+ date?: string;
327
+ }
328
+
329
+ /**
330
+ * One call to an email service's helpers (`ctx.svc.<name>.lastEmail(...)`
331
+ * etc.). Single-message ops embed the full captured message — including its
332
+ * HTML body — so timelines can render the email itself; listing ops embed
333
+ * compact summaries. The return value is `wrap()`ped against this event's
334
+ * seq, so `expect(...)` on it nests under this step (same mechanism as
335
+ * http/db/fake). Inside a `ctx.poll` predicate the event ride-alongs with
336
+ * the poll's iteration events: failed iterations get truncated, the winning
337
+ * one survives as a child of the `wait` event.
338
+ */
339
+ export interface EmailEvent extends BaseEvent {
340
+ kind: "email";
341
+ /** Service key of the mail server (`ctx.svc.<service>`). */
342
+ service: string;
343
+ /** Helper called, e.g. `"lastEmail"`. */
344
+ op: string;
345
+ /** Human-readable match criteria, e.g. `to alice@example.com`. */
346
+ query?: string;
347
+ /** Number of matching messages (listing ops / mailbox size on error). */
348
+ count?: number;
349
+ /** The captured message (single-message ops). */
350
+ message?: EmailEventMessage;
351
+ /** Message summaries (listing ops). */
352
+ messages?: EmailEventSummary[];
353
+ durationMs: number;
354
+ /** Set if the op threw (e.g. the mail server's query API failed). */
355
+ error?: string;
356
+ }
357
+
358
+ /**
359
+ * A runtime mutation of the environment — `ctx.startService` /
360
+ * `ctx.stopService` / `ctx.dnsName`, whether called from a test or from a
361
+ * fake handler reacting to the app under test. Distinct from the `http`
362
+ * event the app generates when it calls the fake: this is the
363
+ * infrastructure the fake (or test) created in response. No-op outside a
364
+ * running test (bootstrap / project-setup / eval), same as every record*.
365
+ */
366
+ export interface EnvEvent extends BaseEvent {
367
+ kind: "env";
368
+ /** Which environment primitive ran. */
369
+ op: "startService" | "stopService" | "dnsName" | "certificate";
370
+ /** SANs of a minted leaf (certificate). */
371
+ hostnames?: string[];
372
+ /** Service name (startService / stopService). */
373
+ service?: string;
374
+ /** Image reference started (startService). */
375
+ image?: string;
376
+ /** Resolved IP — the new container's (startService) or the target's (dnsName). */
377
+ ip?: string;
378
+ /** Hostname registered (dnsName). */
379
+ hostname?: string;
380
+ durationMs: number;
381
+ /** Set if the op threw. */
382
+ error?: string;
383
+ }
384
+
385
+ // A browser step's action label. Since the Playwright-native surface exposes
386
+ // the full locator method vocabulary (click/fill/press/getAttribute/…) plus
387
+ // the session verbs (goto/goBack/evaluate/waitForFunction/screenshot/…), this
388
+ // is an open string rather than a closed union — the value is the Playwright
389
+ // method name verbatim. Both renderers (control-plane web + CLI) treat it as
390
+ // an opaque string with a generic `{action} <target>` fallback, so new method
391
+ // names need no Rust change. Representative values: "goto", "goBack",
392
+ // "goForward", "reload", "evaluate", "waitForFunction",
393
+ // "click", "dblclick", "tap", "fill", "clear", "press", "type", "scroll",
394
+ // "check", "hover", "textContent", "inputValue", "count", "screenshot".
395
+ export type BrowserAction = string;
396
+
397
+ export interface BrowserEvent extends BaseEvent {
398
+ kind: "browser";
399
+ action: BrowserAction;
400
+ /** Target URL (navigate). */
401
+ url?: string;
402
+ /** CSS selector (click, scrollTo). */
403
+ selector?: string;
404
+ /** Human-readable label for `evaluate` ops. */
405
+ description?: string;
406
+ /** Evaluated script, truncated. */
407
+ script?: string;
408
+ scriptTruncated?: boolean;
409
+ /** Typed text, truncated. */
410
+ text?: string;
411
+ textTruncated?: boolean;
412
+ /** Key name (press). */
413
+ key?: string;
414
+ /** Attribute name (getAttribute). */
415
+ attribute?: string;
416
+ /** Scroll/click coordinates. */
417
+ dx?: number;
418
+ dy?: number;
419
+ x?: number;
420
+ y?: number;
421
+ /** Screenshot image format. */
422
+ format?: string;
423
+ /** For `waitFor`: how many times the predicate was polled. */
424
+ attempts?: number;
425
+ durationMs: number;
426
+ error?: string;
427
+ /**
428
+ * Session this op belonged to. Set whenever the Browser was opened
429
+ * with a `BrowserSessionRecorder` attached (the daemon always does).
430
+ */
431
+ sessionId?: string;
432
+ /**
433
+ * Wall-clock `Date.now()` captured at the moment this op *finished*.
434
+ * Lives in the same time base as rrweb's `event.timestamp` fields,
435
+ * so the dashboard can seek the player by computing
436
+ * `event.timestamp - events[0].timestamp`. Using the post-op time
437
+ * (rather than op start) means clicking a "type 'foo'" step lands
438
+ * on the frame where the field already shows the text.
439
+ */
440
+ sessionTimestamp?: number;
441
+ }
442
+
443
+ /**
444
+ * Inline event for a single `ctx.terminal(...)` call. The heavy
445
+ * asciicast frames live separately on a `TerminalSessionRecord`
446
+ * (mirroring how `BrowserEvent` points at a `BrowserSessionRecord`).
447
+ * The UI shows this row in the step list and seeks the player to
448
+ * the session's start when clicked.
449
+ */
450
+ export interface TerminalEvent extends BaseEvent {
451
+ kind: "terminal";
452
+ service: string;
453
+ command: string;
454
+ exitCode: number;
455
+ durationMs: number;
456
+ /** Links this event to its TerminalSessionRecord. */
457
+ sessionId: string;
458
+ /** PTY size used for the run. */
459
+ cols: number;
460
+ rows: number;
461
+ /** First N bytes of decoded output, for tooltip/inline preview. */
462
+ outputPreview: string;
463
+ outputTruncated: boolean;
464
+ /** Set if spawning the PTY itself failed. */
465
+ error?: string;
466
+ }
467
+
468
+ /** Action taken on an open interactive terminal session. */
469
+ export type TerminalStepAction =
470
+ | "send"
471
+ | "sendLine"
472
+ | "press"
473
+ | "waitFor"
474
+ | "exit"
475
+ | "close";
476
+
477
+ /**
478
+ * One operation on an open interactive terminal — analogous to
479
+ * `BrowserEvent`. The session's heavy asciicast frames live on the
480
+ * `TerminalSessionRecord`; this event just carries metadata so the
481
+ * step shows up in the timeline and the UI can seek the player.
482
+ */
483
+ export interface TerminalStepEvent extends BaseEvent {
484
+ kind: "terminal-step";
485
+ /** Links this event back to its TerminalSessionRecord. */
486
+ sessionId: string;
487
+ /** Service the terminal is attached to (mirrors TerminalEvent.service). */
488
+ service: string;
489
+ /** Which Terminal method produced this event. */
490
+ action: TerminalStepAction;
491
+ /** Bytes sent (send/sendLine), truncated. */
492
+ text?: string;
493
+ textTruncated?: boolean;
494
+ /** Key name passed to `press`. */
495
+ key?: string;
496
+ /** Human label passed to `waitFor`. */
497
+ description?: string;
498
+ /** How many polls `waitFor` made. */
499
+ attempts?: number;
500
+ /** Whether `waitFor` matched (false means it timed out). */
501
+ matched?: boolean;
502
+ /** Exit code observed on `close`. */
503
+ exitCode?: number;
504
+ /** Rendered screen at the moment of the op (post-action), truncated. */
505
+ screenPreview: string;
506
+ screenTruncated: boolean;
507
+ /**
508
+ * Player-relative offset, in seconds, captured against the *same*
509
+ * clock the asciicast frames use (the terminal factory's spawn
510
+ * time). The UI seeks the asciinema-player to this value when the
511
+ * step is clicked. Persisting it directly avoids ever subtracting
512
+ * `step.tOffsetMs - session.openedAtMs` on the front-end, which is
513
+ * fragile because the two timestamps live in slightly different
514
+ * Date.now() frames (recorder vs. spawn).
515
+ */
516
+ castTimeSec: number;
517
+ durationMs: number;
518
+ error?: string;
519
+ }
520
+
521
+ /** An ordering slot claimed at op *start* and handed back to the matching
522
+ * `record*` call at op finish. Lets a triggering op (an `exec`/`fetch` that
523
+ * reaches the app, which calls a fake, which calls `ctx.startService`) keep a
524
+ * lower seq than the nested events it sets off — even though those nested
525
+ * events finish, and record, first. */
526
+ export interface EventReservation {
527
+ seq: number;
528
+ tOffsetMs: number;
529
+ }
530
+
531
+ class Recorder {
532
+ private events: TestEvent[] = [];
533
+ private seq = 0;
534
+ private readonly start = Date.now();
535
+
536
+ /** Claim a seq + start offset now; pass the result to `push` at finish. */
537
+ reserve(): EventReservation {
538
+ return { seq: this.seq++, tOffsetMs: Date.now() - this.start };
539
+ }
540
+
541
+ push(
542
+ ev: Omit<TestEvent, "seq" | "tOffsetMs">,
543
+ reservation?: EventReservation,
544
+ ): number {
545
+ // A new op invalidates any pending nullish-leaf note: a note must not
546
+ // survive past the op it belongs to and get adopted by a later, unrelated
547
+ // `expect`. The assertion event itself is exempt — its `expect()` already
548
+ // adopted (and consumed) the note before recording.
549
+ if (ev.kind !== "assertion") clearPendingNullish();
550
+ const seq = reservation?.seq ?? this.seq++;
551
+ this.events.push({
552
+ ...ev,
553
+ seq,
554
+ tOffsetMs: reservation?.tOffsetMs ?? Date.now() - this.start,
555
+ } as TestEvent);
556
+ return seq;
557
+ }
558
+
559
+ drain(): TestEvent[] {
560
+ return this.events;
561
+ }
562
+
563
+ /** Index of the next event that would be pushed. Use with `truncate`
564
+ * to drop everything pushed since this index. The seq counter does
565
+ * not roll back — discarded seqs leave gaps. */
566
+ eventCount(): number {
567
+ return this.events.length;
568
+ }
569
+
570
+ /** Drop events with index >= `toLen`. The seq counter is unaffected
571
+ * (so seqs already handed out remain unique even though the events
572
+ * they named are gone). */
573
+ truncate(toLen: number): void {
574
+ if (toLen < this.events.length) {
575
+ this.events.length = toLen;
576
+ }
577
+ }
578
+
579
+ /** Stamp `parentSeq` onto every event at index >= `startIdx`. Used
580
+ * by `ctx.poll` to group the kept iteration's events under the
581
+ * resulting wait event. */
582
+ markChildren(startIdx: number, parentSeq: number): void {
583
+ for (let i = startIdx; i < this.events.length; i++) {
584
+ const ev = this.events[i]!;
585
+ if (ev.seq === parentSeq) continue;
586
+ ev.parentSeq = parentSeq;
587
+ }
588
+ }
589
+
590
+ /** Shallow-merge `patch` into the already-recorded event with this
591
+ * `seq`. Lets an instrumentation site enrich or reclassify an event
592
+ * after the fact — e.g. the k3s component upgrades the generic `http`
593
+ * event its API call produced into a Kubernetes-specific `kube` event
594
+ * once it has parsed the request. Searches from the end since the
595
+ * target is almost always the most recent event. No-op when no event
596
+ * carries that seq. */
597
+ annotate(seq: number, patch: Record<string, unknown>): void {
598
+ for (let i = this.events.length - 1; i >= 0; i--) {
599
+ const ev = this.events[i]!;
600
+ if (ev.seq === seq) {
601
+ Object.assign(ev, patch);
602
+ return;
603
+ }
604
+ }
605
+ }
606
+
607
+ /** Drop the single event carrying this `seq`. Used to retract an event
608
+ * that an instrumentation site recorded but then decided is noise — e.g.
609
+ * the dynamic kube client's internal discovery GET. Caller must ensure
610
+ * nothing references the seq (no child events/assertions hang off it).
611
+ * The seq counter does not roll back, so the seq stays retired. Searches
612
+ * from the end since the target is almost always the most recent event. */
613
+ remove(seq: number): void {
614
+ for (let i = this.events.length - 1; i >= 0; i--) {
615
+ if (this.events[i]!.seq === seq) {
616
+ this.events.splice(i, 1);
617
+ return;
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ let current: Recorder | null = null;
624
+
625
+ // Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
626
+ // (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
627
+ // suppressed until its own resume balances out). Active record* sites
628
+ // no-op while `paused > 0`, so http/exec/db/etc. inside a paused
629
+ // region produce no events and the returned seq is `undefined` —
630
+ // which means the daemon's fetch wrapper also skips installing the
631
+ // inspector proxy on the Response.
632
+ let paused = 0;
633
+
634
+ export function pauseRecording(): void {
635
+ paused += 1;
636
+ }
637
+
638
+ export function resumeRecording(): void {
639
+ paused = Math.max(0, paused - 1);
640
+ }
641
+
642
+ function active(): boolean {
643
+ return current !== null && paused === 0;
644
+ }
645
+
646
+ export function startRecording(): void {
647
+ current = new Recorder();
648
+ paused = 0;
649
+ }
650
+
651
+ export function stopRecording(): TestEvent[] {
652
+ if (!current) return [];
653
+ const evs = current.drain();
654
+ current = null;
655
+ paused = 0;
656
+ return evs;
657
+ }
658
+
659
+ export function isRecording(): boolean {
660
+ return current !== null;
661
+ }
662
+
663
+ /** Reserve an ordering slot at the start of an op so nested events it triggers
664
+ * (which finish — and record — first) still sort after it. Hand the returned
665
+ * reservation to the matching `record*` call at op finish. Returns `undefined`
666
+ * when nothing is recording, in which case `record*` falls back to allocating
667
+ * the seq at record time. */
668
+ export function reserveEvent(): EventReservation | undefined {
669
+ return active() ? current!.reserve() : undefined;
670
+ }
671
+
672
+ /** Reserve at op *finish* but backdate the offset by `elapsedMs` — for ops that
673
+ * can't reserve up front because they only know they were an op once they're
674
+ * done (the locator matchers: they poll silently and emit one settled step at
675
+ * the end). Keeps `tOffsetMs` meaning "when the op started" for every kind,
676
+ * which is what lets the dashboard render `tOffsetMs + durationMs` as the
677
+ * step's completion time. Ordering is unaffected — the caller records
678
+ * immediately, so the seq is the one it would have got anyway. */
679
+ export function reserveBackdated(elapsedMs: number): EventReservation | undefined {
680
+ const resv = reserveEvent();
681
+ if (!resv) return undefined;
682
+ return { seq: resv.seq, tOffsetMs: Math.max(0, resv.tOffsetMs - Math.max(0, elapsedMs)) };
683
+ }
684
+
685
+ export function recordExec(
686
+ ev: Omit<ExecEvent, "seq" | "tOffsetMs" | "kind">,
687
+ reservation?: EventReservation,
688
+ ): number | undefined {
689
+ return active() ? current!.push({ kind: "exec", ...ev }, reservation) : undefined;
690
+ }
691
+
692
+ export function recordAssertion(
693
+ ev: Omit<AssertionEvent, "seq" | "tOffsetMs" | "kind">,
694
+ ): number | undefined {
695
+ return active() ? current!.push({ kind: "assertion", ...ev }) : undefined;
696
+ }
697
+
698
+ export function recordHttp(
699
+ ev: Omit<HttpEvent, "seq" | "tOffsetMs" | "kind">,
700
+ reservation?: EventReservation,
701
+ ): number | undefined {
702
+ return active() ? current!.push({ kind: "http", ...ev }, reservation) : undefined;
703
+ }
704
+
705
+ export function recordBrowser(
706
+ ev: Omit<BrowserEvent, "seq" | "tOffsetMs" | "kind">,
707
+ reservation?: EventReservation,
708
+ ): number | undefined {
709
+ return active() ? current!.push({ kind: "browser", ...ev }, reservation) : undefined;
710
+ }
711
+
712
+ export function recordDb(
713
+ ev: Omit<DbEvent, "seq" | "tOffsetMs" | "kind">,
714
+ reservation?: EventReservation,
715
+ ): number | undefined {
716
+ return active() ? current!.push({ kind: "db", ...ev }, reservation) : undefined;
717
+ }
718
+
719
+ export function recordRedis(
720
+ ev: Omit<RedisEvent, "seq" | "tOffsetMs" | "kind">,
721
+ reservation?: EventReservation,
722
+ ): number | undefined {
723
+ return active() ? current!.push({ kind: "redis", ...ev }, reservation) : undefined;
724
+ }
725
+
726
+ export function recordS3(
727
+ ev: Omit<S3Event, "seq" | "tOffsetMs" | "kind">,
728
+ reservation?: EventReservation,
729
+ ): number | undefined {
730
+ return active() ? current!.push({ kind: "s3", ...ev }, reservation) : undefined;
731
+ }
732
+
733
+ export function recordTerminal(
734
+ ev: Omit<TerminalEvent, "seq" | "tOffsetMs" | "kind">,
735
+ reservation?: EventReservation,
736
+ ): number | undefined {
737
+ return active() ? current!.push({ kind: "terminal", ...ev }, reservation) : undefined;
738
+ }
739
+
740
+ export function recordTerminalStep(
741
+ ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">,
742
+ reservation?: EventReservation,
743
+ ): number | undefined {
744
+ return active() ? current!.push({ kind: "terminal-step", ...ev }, reservation) : undefined;
745
+ }
746
+
747
+ export function recordFake(
748
+ ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">,
749
+ reservation?: EventReservation,
750
+ ): number | undefined {
751
+ return active() ? current!.push({ kind: "fake", ...ev }, reservation) : undefined;
752
+ }
753
+
754
+ export function recordEnv(
755
+ ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">,
756
+ reservation?: EventReservation,
757
+ ): number | undefined {
758
+ return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
759
+ }
760
+
761
+ export function recordEmail(
762
+ ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
763
+ reservation?: EventReservation,
764
+ ): number | undefined {
765
+ return active() ? current!.push({ kind: "email", ...ev }, reservation) : undefined;
766
+ }
767
+
768
+ /**
769
+ * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
770
+ * intermediate iterations and gives downstream tagged values
771
+ * (`wrap(polledValue, waitSeq)`) a single, stable event to point at —
772
+ * assertions on those values then link to the wait, not to one of N
773
+ * dropped polls.
774
+ */
775
+ export interface WaitEvent extends BaseEvent {
776
+ kind: "wait";
777
+ description: string;
778
+ attempts: number;
779
+ durationMs: number;
780
+ passed: boolean;
781
+ /** Set when the predicate threw or the wait timed out. */
782
+ error?: string;
783
+ }
784
+
785
+ export function recordWait(
786
+ ev: Omit<WaitEvent, "seq" | "tOffsetMs" | "kind">,
787
+ reservation?: EventReservation,
788
+ ): number | undefined {
789
+ // Use `current` directly (not `active()`) so a `recordWait` at the
790
+ // end of a `ctx.poll` block lands even though the surrounding code
791
+ // just resumed from `paused`. Pushing this event is the whole point
792
+ // of the poll primitive.
793
+ return current?.push({ kind: "wait", ...ev }, reservation);
794
+ }
795
+
796
+ /** Number of events the active recorder has accumulated. Returns 0
797
+ * when no recorder is active. */
798
+ export function recorderEventCount(): number {
799
+ return current?.eventCount() ?? 0;
800
+ }
801
+
802
+ /** Drop events with index >= `toLen` from the active recorder. */
803
+ export function recorderTruncate(toLen: number): void {
804
+ current?.truncate(toLen);
805
+ }
806
+
807
+ /** Stamp `parentSeq` onto every event from `startIdx` onward, so the
808
+ * UI groups them under the parent in the timeline. */
809
+ export function recorderMarkChildren(
810
+ startIdx: number,
811
+ parentSeq: number,
812
+ ): void {
813
+ current?.markChildren(startIdx, parentSeq);
814
+ }
815
+
816
+ /** Shallow-merge `patch` into the active recorder's event with this
817
+ * `seq` (enrich/reclassify after the fact). No-op when nothing is
818
+ * recording or no event carries that seq. */
819
+ export function recorderAnnotate(
820
+ seq: number,
821
+ patch: Record<string, unknown>,
822
+ ): void {
823
+ current?.annotate(seq, patch);
824
+ }
825
+
826
+ /** Retract the active recorder's event with this `seq` (an instrumentation
827
+ * site recorded it, then decided it's noise). No-op when nothing is
828
+ * recording or no event carries that seq. */
829
+ export function recorderRemove(seq: number): void {
830
+ current?.remove(seq);
831
+ }
832
+
833
+ /** Best-effort JSON-safe deep clone; falls back to `String(v)`. */
834
+ export function safeSerialize(v: unknown): unknown {
835
+ if (v === undefined) return undefined;
836
+ try {
837
+ return JSON.parse(JSON.stringify(v));
838
+ } catch {
839
+ return String(v);
840
+ }
841
+ }
842
+
843
+ export function truncateUtf8(s: string): { value: string; truncated: boolean } {
844
+ if (s.length <= OUTPUT_SNIPPET_BYTES) return { value: s, truncated: false };
845
+ return { value: s.slice(0, OUTPUT_SNIPPET_BYTES), truncated: true };
846
+ }