@specific.dev/spectest 0.39.0 → 0.43.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 (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
package/src/inspect.ts ADDED
@@ -0,0 +1,739 @@
1
+ // Provenance wrappers for op return values.
2
+ //
3
+ // When a tracked op (fetch, db query, browser.evaluate) records its
4
+ // event, it wraps its return value via `wrap(value, sourceSeq)`. The
5
+ // wrapper carries an `OP_TAG` describing which op produced it and the
6
+ // access path within that op's payload. When the wrapped value reaches
7
+ // `expect()`, the matcher reads the tag and attaches `sourceSeq` + `path`
8
+ // to its AssertionEvent so the UI can nest the assertion under its
9
+ // originating op.
10
+ //
11
+ // Two shapes of wrapper:
12
+ // - Objects/arrays: lazy `Proxy`. Reading a property returns a fresh
13
+ // wrapped child with the path extended. Structural shape stays raw:
14
+ // an array's `length` is a real number (provenance belongs on the
15
+ // data, not the container's size — `rows.length === 1` must work).
16
+ // - Primitives: a small `Carrier` object holding the value and tag.
17
+ // Coercion sinks (`valueOf`/`toString`/`toJSON`/`Symbol.toPrimitive`)
18
+ // recover the raw primitive, so template interpolation, arithmetic,
19
+ // loose equality, and `JSON.stringify` all behave. The one remaining
20
+ // sharp edge is strict `===` against a primitive (always false — an
21
+ // object can never `===` a number); `.unwrap()` first, and the
22
+ // `Carrier<T>` typing makes that a compile-time error in TS.
23
+ //
24
+ // Cannot tag: `null` / `undefined` (no place to attach a symbol) and
25
+ // functions (we don't currently need to). Those pass through as-is — but a
26
+ // nullish *leaf read* off a proxy is recorded in the pending-nullish register
27
+ // below so `expect()` can still recover its provenance (see `adoptNullishTag`).
28
+ //
29
+ // Escape: every wrapper exposes `.unwrap()` returning the fully raw value (it
30
+ // walks every wrapper layer, so one call always reaches raw). That is the
31
+ // public escape hatch; there is no `unwrap(x)` free function — a spectest op
32
+ // result is always wrapped (see below), so the value always has `.unwrap()`.
33
+ // (`readRaw` is the internal, never-throws primitive `.unwrap()` and `expect()`
34
+ // are built on; it isn't part of the public surface.)
35
+ //
36
+ // Wrapping is UNCONDITIONAL: a spectest op (fetch/exec/terminal/poll/db/
37
+ // browser.evaluate) always returns a wrapped value, in every context — a test,
38
+ // a `ctx.poll` predicate, `setup`, `eval`, a fake handler. Whether the op also
39
+ // recorded a timeline *event* is a separate decision (the recorder may be
40
+ // absent, paused, or have truncated the event): when there is no event the
41
+ // wrapper simply carries no provenance (`sourceSeq: undefined`), so `expect()`
42
+ // can't nest it under an op — but the wrapper SHAPE (`.unwrap()`, coercion
43
+ // sinks, the proxy) is always present. That keeps `Wrapped`/`WrappedResponse`/
44
+ // `Carrier<T>` honest at runtime everywhere, instead of silently collapsing to
45
+ // a raw value wherever recording happened to be off.
46
+
47
+ // Registered in the GLOBAL symbol registry (`Symbol.for`), not module-private
48
+ // (`Symbol`), so a value tagged by ONE copy of this module unwraps correctly in
49
+ // ANOTHER. This matters whenever two copies of the SDK load in the same runtime:
50
+ // the in-VM daemon runs the baked SDK at /opt/spectest/sdk and mints the
51
+ // provenance carriers (ctx.fetch / ctx.browser / db results), while a user's
52
+ // test file resolving `@specific.dev/spectest` to a registry-pinned copy gets
53
+ // its `expect`. With module-private symbols the two `UNWRAP`s differed, so
54
+ // `expect`'s auto-unwrap silently no-op'd and wrapped assertions failed
55
+ // nonsensically (`expect("Hello Spec").toContain("Hello")` -> false). The
56
+ // control plane also repoints the dep at the baked copy so normally only one
57
+ // copy loads; this is defense-in-depth for the cases where it can't (and only
58
+ // takes full effect once both copies ship this `Symbol.for`).
59
+ export const OP_TAG = Symbol.for("spectest.opTag");
60
+ export const UNWRAP = Symbol.for("spectest.unwrap");
61
+
62
+ // The escape hatch from a wrapped value to its raw form is the `.unwrap()`
63
+ // method that lives on the `Carrier` / `WrappedObject` / `WrappedArray` /
64
+ // `WrappedResponse` types. We deliberately do NOT augment the global
65
+ // `Number`/`String`/`Boolean`/`Object` prototypes with a phantom `unwrap()`:
66
+ // that made `.unwrap()` typecheck on *every* value, including a genuinely raw
67
+ // primitive (a third-party return, or a plain `fetch` the project code calls
68
+ // itself), where `.unwrap()` would compile but throw at runtime. Without the
69
+ // phantom, `.unwrap()` only typechecks on a value whose static type is one of
70
+ // the wrapper types — and because spectest ops now wrap unconditionally (see
71
+ // above), those static types are honest at runtime in every context. A value
72
+ // that is raw *and* typed raw (it came from outside a spectest op) neither
73
+ // needs nor offers `.unwrap()` — use it directly.
74
+
75
+ export interface OpTag {
76
+ /** Seq of the timeline event this value came from, or `undefined` when the
77
+ * value was wrapped without a recorded event (no recorder, paused, or the
78
+ * event was truncated). `expect()` only nests under a defined `sourceSeq`. */
79
+ sourceSeq: number | undefined;
80
+ path: readonly string[];
81
+ }
82
+
83
+ /** Read the tag if present; returns undefined for raw values. */
84
+ export function readTag(x: unknown): OpTag | undefined {
85
+ if (x === null || x === undefined) return undefined;
86
+ if (typeof x !== "object" && typeof x !== "function") return undefined;
87
+ return (x as { [OP_TAG]?: OpTag })[OP_TAG];
88
+ }
89
+
90
+ // ───────────────────────────────────────────────────────────────────────────
91
+ // Pending nullish-leaf register
92
+ //
93
+ // Reading a `null`/`undefined` leaf off a wrapped object (`dep.status.readyReplicas`
94
+ // on a failed deployment) hands back a raw nullish value with no tag — a symbol
95
+ // can't ride on `null`/`undefined`, and minting a stand-in object would break
96
+ // every `=== null` / `if (!x)` in real code (see `wrap`). So the natural
97
+ // `expect(dep.status.readyReplicas).toBeFalsy()` used to lose its provenance and
98
+ // render as a disconnected top-level assertion, even though `field(...)` could
99
+ // recover it manually.
100
+ //
101
+ // To make the natural form work too, the proxy *notes* every nullish leaf read
102
+ // here (source op + access path). `expect()` consults the register when it
103
+ // receives an untagged nullish value via `adoptNullishTag`, matching the most
104
+ // recent note. The note is single-consume, freshest-wins, and invalidated by
105
+ // any subsequent recorded op (the recorder calls `clearPendingNullish`), so the
106
+ // window for misattribution is one untagged nullish read immediately followed by
107
+ // an `expect` of an unrelated nullish literal with no op in between — narrow,
108
+ // and the worst case points at a contextually-adjacent field rather than losing
109
+ // the link entirely. The note never reaches program control flow: it is read
110
+ // only inside `expect()`, which `readRaw`s back to the real nullish value.
111
+ // ───────────────────────────────────────────────────────────────────────────
112
+
113
+ let pendingNullish: { value: null | undefined; tag: OpTag } | undefined;
114
+
115
+ function noteNullishLeaf(
116
+ value: null | undefined,
117
+ sourceSeq: number | undefined,
118
+ path: readonly string[],
119
+ ): void {
120
+ pendingNullish = { value, tag: { sourceSeq, path: [...path] } };
121
+ }
122
+
123
+ /** Forget any pending nullish-leaf note. Called by the recorder whenever a new
124
+ * op is recorded, so a note can't outlive the read that produced it. */
125
+ export function clearPendingNullish(): void {
126
+ pendingNullish = undefined;
127
+ }
128
+
129
+ /**
130
+ * If `value` is an untagged `null`/`undefined` that matches the most recent
131
+ * nullish-leaf read, mint a tagged {@link makeCarrier} holder for it (the same
132
+ * stand-in `field`/`retag` use) and clear the note; otherwise return `value`
133
+ * unchanged. The holder is safe because it goes straight to `expect()`, which
134
+ * `readRaw`s it before matching — it never escapes into control flow.
135
+ */
136
+ export function adoptNullishTag<T>(value: T): T {
137
+ if (value !== null && value !== undefined) return value;
138
+ const p = pendingNullish;
139
+ if (!p || !Object.is(p.value, value)) return value;
140
+ pendingNullish = undefined;
141
+ return makeCarrier(value, p.tag.sourceSeq, p.tag.path) as unknown as T;
142
+ }
143
+
144
+ /** If x is wrapped, return the raw value; otherwise return x. */
145
+ export function readRaw<T>(x: T): T {
146
+ // Walks the chain — a value can carry more than one wrapper at once
147
+ // (e.g. a polled k8s pod is wrapped by the component's `withTagging`
148
+ // with the HTTP seq, then by `ctx.poll`'s `wrap(...)` with the wait
149
+ // seq). A single-step unwrap would still hand callers a proxy.
150
+ let cur: unknown = x;
151
+ while (cur !== null && cur !== undefined) {
152
+ const t = typeof cur;
153
+ if (t !== "object" && t !== "function") return cur as T;
154
+ // Presence check, NOT `next === undefined`: a carrier minted by `field`
155
+ // may wrap `undefined` itself (`[UNWRAP] = undefined`). Testing the value
156
+ // would mistake that for "no wrapper" and hand the carrier object back, so
157
+ // a `toBeFalsy()` would see a truthy object.
158
+ if (!(UNWRAP in (cur as object))) return cur as T;
159
+ const next = (cur as { [UNWRAP]?: unknown })[UNWRAP];
160
+ if (next === cur) return cur as T;
161
+ cur = next;
162
+ }
163
+ return cur as T;
164
+ }
165
+
166
+ /**
167
+ * Strip provenance wrappers from a value AND from anything nested inside a
168
+ * plain object/array it holds. The inbound counterpart of {@link wrap}: for
169
+ * values crossing *back* into code that expects raw data — a fake's `helpers`,
170
+ * a k8s client method's arguments. Without it a helper that takes a value some
171
+ * earlier op produced gets a `Carrier`/proxy, and the first `.matchAll(...)` /
172
+ * typed-client call inside it throws a `TypeError` from deep in the callee,
173
+ * far from the call site that passed the wrapper. Coercion sinks
174
+ * ({@link makeCarrier}) rescue only the interpolation cases; a callee that
175
+ * calls a method on the value, or checks its `typeof`, still sees an object.
176
+ *
177
+ * One {@link readRaw} finishes a wrapped value: a wrapper's target is raw all
178
+ * the way down (the proxy wraps children lazily, on read), so there is nothing
179
+ * left to walk underneath it. The recursion is for the other shape — a
180
+ * container the *caller* built around wrapped leaves: `{ to: msg.to }`,
181
+ * `[row.id, row.email]`.
182
+ *
183
+ * Rebuilds only what changed, and only plain objects/arrays: a `Date`,
184
+ * `Buffer`, `Map`, `Response` or class instance is returned untouched rather
185
+ * than flattened into a plain object. Cyclic containers are left in place at
186
+ * the point the cycle closes.
187
+ */
188
+ export function deepUnwrap<T>(value: T): T {
189
+ return deepUnwrapInner(value, new Set()) as T;
190
+ }
191
+
192
+ function deepUnwrapInner(value: unknown, seen: Set<object>): unknown {
193
+ if (value === null || value === undefined) return value;
194
+ const raw = readRaw(value);
195
+ // It was wrapped, so it's now fully raw — and recursing into it would be
196
+ // wrong anyway (the raw form may be an exotic object we must not rebuild).
197
+ if (raw !== value) return raw;
198
+ if (typeof raw !== "object") return raw;
199
+ const obj = raw as object;
200
+ if (seen.has(obj)) return obj;
201
+ if (Array.isArray(obj)) {
202
+ seen.add(obj);
203
+ let changed = false;
204
+ const out = obj.map((el) => {
205
+ const next = deepUnwrapInner(el, seen);
206
+ if (next !== el) changed = true;
207
+ return next;
208
+ });
209
+ seen.delete(obj);
210
+ return changed ? out : obj;
211
+ }
212
+ const proto = Object.getPrototypeOf(obj);
213
+ if (proto !== Object.prototype && proto !== null) return obj;
214
+ seen.add(obj);
215
+ let changed = false;
216
+ const out: Record<string, unknown> = {};
217
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
218
+ const next = deepUnwrapInner(v, seen);
219
+ if (next !== v) changed = true;
220
+ out[k] = next;
221
+ }
222
+ seen.delete(obj);
223
+ return changed ? out : obj;
224
+ }
225
+
226
+ /** The raw type behind a wrapper: a `Carrier`/`WrappedObject`/`WrappedArray`/
227
+ * `WrappedResponse` resolves to its `unwrap()` return type; anything else is
228
+ * already raw and passes through unchanged. */
229
+ export type Unwrap<T> = T extends { unwrap(): infer V } ? V : T;
230
+
231
+ /**
232
+ * Wrap a value so reads through it carry an `OpTag`. Recursion is lazy:
233
+ * a property read on an object Proxy wraps its child on demand.
234
+ */
235
+ export function wrap<T>(
236
+ raw: T,
237
+ sourceSeq: number | undefined,
238
+ path: readonly string[] = [],
239
+ ): T {
240
+ if (raw === null || raw === undefined) return raw;
241
+ const t = typeof raw;
242
+ if (t === "object") {
243
+ return wrapObject(raw as object, sourceSeq, path) as unknown as T;
244
+ }
245
+ if (t === "function") return raw;
246
+ // primitive
247
+ return makeCarrier(raw, sourceSeq, path) as unknown as T;
248
+ }
249
+
250
+ /**
251
+ * Like {@link wrap}, but for values read *through* a wrapped container, where a
252
+ * `null`/`undefined` leaf must still leave a provenance trail. `wrap` passes
253
+ * nullish through raw (a symbol can't ride on it); here we additionally
254
+ * {@link noteNullishLeaf note} the read so `expect()` can adopt the tag via
255
+ * {@link adoptNullishTag}. The returned value is identical to `wrap`'s — raw
256
+ * nullish or a proxy/carrier — so control flow is unaffected.
257
+ */
258
+ function wrapChild<T>(value: T, sourceSeq: number | undefined, path: readonly string[]): T {
259
+ if (value === null || value === undefined) {
260
+ noteNullishLeaf(value as null | undefined, sourceSeq, path);
261
+ return value;
262
+ }
263
+ return wrap(value, sourceSeq, path);
264
+ }
265
+
266
+ // Array methods whose return value derives from the array's contents.
267
+ // We re-wrap their results so assertions on `arr.find(...)` etc. still
268
+ // fold under the originating op. Predicates still see raw items (the
269
+ // method runs on the raw target), so `===` comparisons keep working.
270
+ const ARRAY_TAGGED_METHODS = new Set([
271
+ "find",
272
+ "findLast",
273
+ "at",
274
+ "map",
275
+ "filter",
276
+ "slice",
277
+ "flat",
278
+ "flatMap",
279
+ "concat",
280
+ "includes",
281
+ "indexOf",
282
+ "lastIndexOf",
283
+ "findIndex",
284
+ "findLastIndex",
285
+ "some",
286
+ "every",
287
+ ]);
288
+
289
+ function wrapObject<T extends object>(
290
+ raw: T,
291
+ sourceSeq: number | undefined,
292
+ path: readonly string[],
293
+ ): T {
294
+ const tag: OpTag = { sourceSeq, path };
295
+ const isArray = Array.isArray(raw);
296
+ const handler: ProxyHandler<T> = {
297
+ get(target, prop, receiver) {
298
+ if (prop === OP_TAG) return tag;
299
+ if (prop === UNWRAP) return target;
300
+ if (prop === "unwrap") {
301
+ // Only intercept when the underlying object doesn't already have
302
+ // its own `unwrap` — otherwise we'd shadow a legitimate property.
303
+ // `readRaw` (not a bare `target`) so a value carrying more than one
304
+ // wrapper — e.g. a `ctx.poll` result whose inner value was already
305
+ // tagged by a component's `withTagging` — unwraps all the way to raw
306
+ // in one call, not just one layer.
307
+ if (!(prop in (target as object))) {
308
+ return () => readRaw(target);
309
+ }
310
+ }
311
+ // `.transform(label, fn)` — same shape/guard as `unwrap`: only intercept
312
+ // when the underlying object has no own `transform`, so a real data field
313
+ // named `transform` is never shadowed.
314
+ if (prop === "transform" && !(prop in (target as object))) {
315
+ return makeTransform(readRaw(target), sourceSeq, path);
316
+ }
317
+ // Hide thenable-ness from `await`. We must never accidentally
318
+ // implement `then`, or `await fetch(...)` would resolve to the
319
+ // wrong thing if we ever wrapped a Promise (we don't, but be safe).
320
+ if (prop === "then" && !(prop in (target as object))) return undefined;
321
+
322
+ const value = Reflect.get(target, prop, receiver);
323
+ if (typeof prop === "symbol") {
324
+ // Symbols (iterator, toPrimitive, etc.) — pass through bound.
325
+ return typeof value === "function"
326
+ ? (value as (...a: unknown[]) => unknown).bind(target)
327
+ : value;
328
+ }
329
+ // Container shape stays raw: `rows.length === 1` must be a plain
330
+ // number comparison, not carrier-vs-number (silently false).
331
+ // Provenance for "how many" assertions still works — `expect`
332
+ // accepts raw values, it just won't nest under the op.
333
+ if (isArray && prop === "length") return value;
334
+ const childPath = path.concat(String(prop));
335
+ if (typeof value === "function") {
336
+ if (isArray && ARRAY_TAGGED_METHODS.has(prop)) {
337
+ const method = value as (...a: unknown[]) => unknown;
338
+ const taggedPath = path.concat(`<${prop}>`);
339
+ return (...args: unknown[]) => {
340
+ const result = method.apply(target, args);
341
+ return wrapChild(result, sourceSeq, taggedPath);
342
+ };
343
+ }
344
+ // Other methods stay bound to the raw target; their return
345
+ // values aren't wrapped here — call sites that need
346
+ // promise-returning methods (e.g. Response.json()) install a
347
+ // bespoke wrapper.
348
+ return (value as (...a: unknown[]) => unknown).bind(target);
349
+ }
350
+ return wrapChild(value, sourceSeq, childPath);
351
+ },
352
+ has(target, prop) {
353
+ if (prop === OP_TAG || prop === UNWRAP) return true;
354
+ return Reflect.has(target, prop);
355
+ },
356
+ // Block writes; wrappers are read-only views.
357
+ set() {
358
+ return false;
359
+ },
360
+ deleteProperty() {
361
+ return false;
362
+ },
363
+ };
364
+ return new Proxy(raw, handler);
365
+ }
366
+
367
+ // The `.transform(label, fn)` method every wrapper (carrier + object/array
368
+ // proxy) carries — the value-level analogue of `.unwrap()`. It runs the raw
369
+ // value through `fn` and re-`wrap`s the result with `path` extended by a
370
+ // `<label>` derived-step marker, so the decoded value keeps the source op's
371
+ // provenance (`sourceSeq`) and stays navigable: a later `expect(...)` on it, or
372
+ // on a subfield, still nests under the originating op (`secret.data.config.<json>.tier`).
373
+ // `label` is a plain word; the `<…>` brackets are added here so they never leak
374
+ // into the call site (the same convention array methods use, `<find>`/`<map>`).
375
+ // A throw in `fn` (a non-string value, malformed base64/JSON) is rethrown
376
+ // prefixed with `<label>:`, failing the test at the bad value.
377
+ function makeTransform(
378
+ raw: unknown,
379
+ sourceSeq: number | undefined,
380
+ path: readonly string[],
381
+ ): (label: string, fn: (raw: never) => unknown) => unknown {
382
+ return (label, fn) => {
383
+ const nextPath = [...path, `<${label}>`];
384
+ let result: unknown;
385
+ try {
386
+ result = (fn as (r: unknown) => unknown)(raw);
387
+ } catch (err) {
388
+ const detail = err instanceof Error ? err.message : String(err);
389
+ throw new Error(`<${label}>: ${detail}`);
390
+ }
391
+ return wrap(result, sourceSeq, nextPath);
392
+ };
393
+ }
394
+
395
+ // Runtime shape of a primitive carrier: the public `Carrier<T>` surface
396
+ // (`unwrap()`) plus the internal provenance symbols. Kept private so the
397
+ // exported `Carrier<T>` stays clean.
398
+ interface CarrierCell<T> extends Carrier<T> {
399
+ [OP_TAG]: OpTag;
400
+ [UNWRAP]: T;
401
+ }
402
+
403
+ function makeCarrier<T>(
404
+ raw: T,
405
+ sourceSeq: number | undefined,
406
+ path: readonly string[],
407
+ ): CarrierCell<T> {
408
+ const tag: OpTag = { sourceSeq, path };
409
+ return {
410
+ [OP_TAG]: tag,
411
+ [UNWRAP]: raw,
412
+ unwrap: () => raw,
413
+ transform: makeTransform(raw, sourceSeq, path) as unknown as Carrier<T>["transform"],
414
+ // Coercion sinks recover the raw primitive instead of inheriting
415
+ // Object.prototype's defaults ("[object Object]" / NaN / {}). A
416
+ // carrier interpolated into a string, fed to arithmetic, `==`, or
417
+ // `JSON.stringify` now behaves like its value — silent wrongness
418
+ // (a bogus "[object Object]" namespace in a kubectl command) was
419
+ // the most expensive failure mode this wrapper ever produced.
420
+ valueOf: () => raw,
421
+ toString: () => String(raw),
422
+ toJSON: () => raw,
423
+ [Symbol.toPrimitive]: () => raw,
424
+ };
425
+ }
426
+
427
+ // ───────────────────────────────────────────────────────────────────────────
428
+ // Provenance-preserving field selection
429
+ //
430
+ // The proxy/carrier only carries the OpTag across *property reads* (and a
431
+ // whitelist of array methods), and it can't carry it across `null`/`undefined`
432
+ // at all — those are primitives with nowhere to hang a symbol, and `wrap`
433
+ // passes them through raw. So `expect(row.deleted_at).toBe(null)` reaches
434
+ // `expect()` as a bare `null` with no tag and renders as a disconnected
435
+ // top-level row. `field` recovers the link by tagging from the *container*.
436
+ // (A decoded value loses its tag the same way, through the decode; the
437
+ // `.transform(label, fn)` method every wrapper carries (see `makeTransform`)
438
+ // recovers it — it runs the still-tagged value through `fn` and re-`wrap`s the
439
+ // result with the source path extended by a `<label>` marker.)
440
+ // ───────────────────────────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Re-tag a value against an existing source op. Uses the normal `wrap` for
444
+ * non-nullish results (object → proxy, primitive → carrier) but a
445
+ * {@link makeCarrier} *holder* for `null`/`undefined` — those can't hold a
446
+ * symbol, and `wrap` deliberately passes them through raw. The holder is an
447
+ * object masquerading as null/undefined, which is ONLY safe because it never
448
+ * escapes into program control flow: it goes straight to `expect()`, which
449
+ * `readRaw`s it back to the real value before matching.
450
+ */
451
+ function retag(raw: unknown, sourceSeq: number | undefined, path: readonly string[]): unknown {
452
+ return raw === null || raw === undefined
453
+ ? makeCarrier(raw, sourceSeq, path)
454
+ : wrap(raw, sourceSeq, path);
455
+ }
456
+
457
+ /**
458
+ * Provenance-preserving, null-safe field selector — for asserting on a leaf
459
+ * that is `null`/`undefined`. Reading such a leaf off a wrapped object hands
460
+ * back a raw `null`/`undefined` (a symbol tag can't ride on those, and minting
461
+ * a stand-in object would break every `=== null` / `if (!x)` in real code), so
462
+ * the raw value alone loses its link to the originating op. `field` reads the
463
+ * tag from the *container* and navigates the raw value, returning a tagged
464
+ * handle even when the leaf is nullish — safe because the handle only ever
465
+ * feeds `expect()`. Pass it straight to `expect(...)`; works with every matcher.
466
+ *
467
+ * Mostly redundant now: the plain `expect(dep.status.readyReplicas).toBeFalsy()`
468
+ * form recovers provenance on its own — the proxy notes each nullish leaf read
469
+ * and `expect` adopts the note (see {@link adoptNullishTag}). Reach for `field`
470
+ * when the nullish read and the `expect` are separated by another recorded op
471
+ * (which clears the note), or to make the navigation explicit.
472
+ *
473
+ * ```ts
474
+ * expect(field(created, "branched_from_environment_id")).toBe(null);
475
+ * expect(field(dep, "status", "readyReplicas")).toBeFalsy();
476
+ * ```
477
+ */
478
+ export function field(value: unknown, ...path: Array<string | number>): unknown {
479
+ const tag = readTag(value);
480
+ let cur: unknown = readRaw(value);
481
+ for (const key of path) {
482
+ if (cur === null || cur === undefined) {
483
+ cur = undefined;
484
+ break;
485
+ }
486
+ cur = (cur as Record<string | number, unknown>)[key];
487
+ }
488
+ if (!tag) return cur;
489
+ return retag(cur, tag.sourceSeq, [...tag.path, ...path.map(String)]);
490
+ }
491
+
492
+ /**
493
+ * A provenance-carrying handle to a value produced by a tracked op
494
+ * (`ctx.fetch`, a db query, `browser.evaluate`). Pass it straight to
495
+ * `expect(...)` — the matcher reads the provenance and nests the
496
+ * assertion under the originating op in the timeline.
497
+ *
498
+ * At runtime, coercion sinks recover the raw value (`` `${carrier}` ``,
499
+ * `JSON.stringify(carrier)` behave as if raw). But the *type* is honestly an
500
+ * object, not `T`, so arithmetic (`carrier + 1`), `==`/`===`, and handing it
501
+ * to a typed API are all compile errors — `carrier.unwrap()` (or `expect(...)`,
502
+ * which unwraps for you) first. This is deliberate: a wrapped value is a
503
+ * handle, and the type says so rather than masquerading as its raw type.
504
+ */
505
+ export interface Carrier<T> {
506
+ /** Recover the raw underlying value. */
507
+ unwrap(): T;
508
+ /**
509
+ * Run the raw value through `fn` and get back a provenance-carrying handle to
510
+ * the result — for asserting on a *decoded* value (base64, JSON, JWT, …) while
511
+ * keeping its link to the op that produced it. The op path is extended by a
512
+ * `<label>` marker, so a later `expect(...)` on the result still nests under
513
+ * the source op. `label` is a plain word (`"base64"`, `"json"`); the UI adds
514
+ * the `<…>` brackets, so don't include them yourself. `fn` receives the raw
515
+ * value (typed `T`), so no cast is needed. `R` types the result. A throw in
516
+ * `fn` is rethrown prefixed with `<label>:`, failing the test at the value.
517
+ *
518
+ * ```ts
519
+ * expect(secret.data.url.transform("base64", (s) => Buffer.from(s, "base64").toString()))
520
+ * .toContain("redis://");
521
+ * ```
522
+ */
523
+ transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
524
+ /** Coerces to the raw value (arithmetic, `==`). */
525
+ valueOf(): T;
526
+ /** Renders the raw value (template interpolation). */
527
+ toString(): string;
528
+ /** Serializes as the raw value under `JSON.stringify`. */
529
+ toJSON(): T;
530
+ /** Coerces to the raw value. */
531
+ [Symbol.toPrimitive](hint?: string): T;
532
+ }
533
+
534
+ /**
535
+ * The values `expect()` accepts: anything carrying provenance from a recorded
536
+ * op (fetch / db / exec / browser / fakes / k8s) — every member of the
537
+ * {@link wrap} family ({@link Carrier}, {@link WrappedObject},
538
+ * {@link WrappedArray}, {@link WrappedResponse}) exposes `.unwrap()`, so this
539
+ * structural shape admits them all and rejects a raw primitive / object.
540
+ *
541
+ * `null`/`undefined` are also admitted: a nullish leaf can't carry the symbol
542
+ * tag, but `adoptNullishTag` recovers its provenance at runtime, so
543
+ * `expect(rows[0]?.text)` and `expect(dep.status.readyReplicas)` stay on
544
+ * `expect`.
545
+ *
546
+ * When this gate rejects a value, the fix is usually to stop flattening it —
547
+ * `.transform(label, fn)` instead of a decode-then-`.unwrap()`, `toHaveLength`
548
+ * instead of `.length` — not to switch to `expectRaw`, which accepts anything
549
+ * but renders the assertion unlinked. Reach for `expectRaw(value, message)`
550
+ * only when the value never flowed from a recorded op at all (a computed
551
+ * number, a frame off a raw WebSocket); see its own doc comment.
552
+ */
553
+ export type Provenanced = { unwrap(): unknown } | null | undefined;
554
+
555
+ /**
556
+ * What a tracked op's value looks like once wrapped — applied *deeply*, so the
557
+ * type matches the runtime at every level (the proxy lazily wraps each leaf you
558
+ * read). A primitive becomes a {@link Carrier}; an object keeps its keys but
559
+ * each property is itself `Wrapped`, plus an `.unwrap()`; an array keeps a raw
560
+ * `.length` and indexes to `Wrapped` elements (see {@link WrappedArray}).
561
+ *
562
+ * Because leaves are `Carrier`s rather than their raw type, using one as raw
563
+ * data — arithmetic, string methods, a typed client — is a compile error;
564
+ * `x.unwrap()` (or `expect(x)`, which unwraps) recovers the raw value. That's
565
+ * the whole point: the type tells you it's a handle instead of pretending to be
566
+ * the underlying value and blowing up at runtime.
567
+ *
568
+ * **Distributes over unions** so the nullish members of an optional property
569
+ * survive as `null`/`undefined` rather than collapsing into a `Carrier<undefined>`.
570
+ * That's what lets `?.` narrow a wrapped optional leaf: `Wrapped<V1PodStatus |
571
+ * undefined>` is `WrappedObject<V1PodStatus> | undefined` (so `pod.status?.phase`
572
+ * type-checks), not `Carrier<undefined> | WrappedObject<…>` (where `?.` can't see
573
+ * the `Carrier` as nullish). A nullish leaf still reaches `expect` untagged and is
574
+ * recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
575
+ * it includes `null | undefined`.
576
+ */
577
+ export type Wrapped<T> = T extends null | undefined
578
+ ? T
579
+ : T extends readonly (infer U)[]
580
+ ? WrappedArray<U>
581
+ : // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
582
+ T extends (...args: never[]) => unknown
583
+ ? T
584
+ : T extends object
585
+ ? WrappedObject<T>
586
+ : Carrier<T>;
587
+
588
+ /** A wrapped object: every own property is itself {@link Wrapped}, plus an
589
+ * `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
590
+ export type WrappedObject<T> = {
591
+ readonly [K in keyof T]: Wrapped<T[K]>;
592
+ } & {
593
+ /** Recover the fully raw value (nested leaves unwrapped too). */
594
+ unwrap(): T;
595
+ /** Run the raw object through `fn`, keeping provenance (op path + `<label>`),
596
+ * and get back a navigable handle to the result. See {@link Carrier.transform}. */
597
+ transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
598
+ };
599
+
600
+ /**
601
+ * A wrapped array. Indexing and the content-deriving methods (`find`, `map`,
602
+ * `filter`, `slice`, …) return {@link Wrapped} values so assertions on them
603
+ * still fold under the originating op; their *predicates* receive raw elements
604
+ * (the method runs on the raw target), so `=== ` comparisons inside a predicate
605
+ * keep working. `.length` stays a real `number` — provenance belongs on the
606
+ * data, not the container's size, so `rows.length === 1` must be a plain
607
+ * comparison. Iteration (`for…of`, spread) yields raw elements.
608
+ */
609
+ export interface WrappedArray<U> {
610
+ readonly length: number;
611
+ readonly [index: number]: Wrapped<U>;
612
+ /** Recover the raw array (elements unwrapped). */
613
+ unwrap(): U[];
614
+ /** Run the raw array through `fn`, keeping provenance (op path + `<label>`),
615
+ * and get back a navigable handle to the result. See {@link Carrier.transform}. */
616
+ transform<R = unknown>(label: string, fn: (raw: U[]) => R): Wrapped<R>;
617
+ at(index: number): Wrapped<U> | undefined;
618
+ find(
619
+ predicate: (value: U, index: number, obj: U[]) => unknown,
620
+ ): Wrapped<U> | undefined;
621
+ findLast(
622
+ predicate: (value: U, index: number, obj: U[]) => unknown,
623
+ ): Wrapped<U> | undefined;
624
+ findIndex(predicate: (value: U, index: number, obj: U[]) => unknown): number;
625
+ findLastIndex(
626
+ predicate: (value: U, index: number, obj: U[]) => unknown,
627
+ ): number;
628
+ filter(
629
+ predicate: (value: U, index: number, array: U[]) => unknown,
630
+ ): WrappedArray<U>;
631
+ map<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
632
+ slice(start?: number, end?: number): WrappedArray<U>;
633
+ concat(...items: U[][]): WrappedArray<U>;
634
+ flat(): WrappedArray<unknown>;
635
+ flatMap<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
636
+ /** Note: returns a `Carrier<boolean>` at runtime (re-wrapped for provenance),
637
+ * so use `arr.includes(x).unwrap()` or `expect(arr.includes(x))` rather than
638
+ * a bare `if`. */
639
+ includes(value: U, fromIndex?: number): Carrier<boolean>;
640
+ indexOf(value: U, fromIndex?: number): Carrier<number>;
641
+ lastIndexOf(value: U, fromIndex?: number): Carrier<number>;
642
+ some(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
643
+ every(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
644
+ /** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
645
+ [Symbol.iterator](): IterableIterator<U>;
646
+ }
647
+
648
+ /**
649
+ * The view `ctx.fetch` resolves to in every context (a spectest op wraps
650
+ * unconditionally): a {@link Response} whose status-line accessors are
651
+ * {@link Carrier}s — so a raw `res.status === 200` is a *type error*
652
+ * (status is a `Carrier<number>`, not a number), the exact mistake that
653
+ * used to silently always be false. Compare `res.status.unwrap() === 200`
654
+ * or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
655
+ *
656
+ * `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
657
+ * (or {@link unwrap the whole response}) recovers the plain `Response`.
658
+ */
659
+ export interface WrappedResponse {
660
+ readonly status: Carrier<number>;
661
+ readonly ok: Carrier<boolean>;
662
+ readonly statusText: Carrier<string>;
663
+ readonly url: Carrier<string>;
664
+ readonly redirected: Carrier<boolean>;
665
+ readonly type: Carrier<string>;
666
+ readonly headers: Headers;
667
+ readonly bodyUsed: boolean;
668
+ json<T = unknown>(): Promise<Wrapped<T>>;
669
+ text(): Promise<Carrier<string>>;
670
+ arrayBuffer(): Promise<ArrayBuffer>;
671
+ blob(): Promise<Blob>;
672
+ formData(): Promise<FormData>;
673
+ clone(): WrappedResponse;
674
+ /** Recover the underlying raw {@link Response} (a real `number` status,
675
+ * an unwrapped body, etc.). */
676
+ unwrap(): Response;
677
+ }
678
+
679
+ /** The signature of `ctx.fetch`: a `fetch` that resolves to a
680
+ * {@link WrappedResponse} so reads carry provenance into assertions. */
681
+ export type SpectestFetch = (
682
+ input: RequestInfo | URL,
683
+ init?: RequestInit,
684
+ ) => Promise<WrappedResponse>;
685
+
686
+ /**
687
+ * Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
688
+ * `statusText` / `url` / `redirected` / `type` return carriers tagged
689
+ * to `sourceSeq`. The body-reading methods (`json`, `text`) return the
690
+ * resolved value wrapped under `path: ["body"]`. Everything else passes
691
+ * through bound to the real Response.
692
+ */
693
+ export function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse {
694
+ const tag: OpTag = { sourceSeq, path: [] };
695
+ const carrierProps = new Set([
696
+ "status",
697
+ "ok",
698
+ "statusText",
699
+ "url",
700
+ "redirected",
701
+ "type",
702
+ ]);
703
+ const bodyMethods = new Set(["json", "text"]);
704
+ const handler: ProxyHandler<Response> = {
705
+ get(target, prop) {
706
+ if (prop === OP_TAG) return tag;
707
+ if (prop === UNWRAP) return target;
708
+ // Full recursive unwrap (see the note in `wrapObject`).
709
+ if (prop === "unwrap") return () => readRaw(target);
710
+ if (prop === "then") return undefined;
711
+
712
+ if (typeof prop === "string" && carrierProps.has(prop)) {
713
+ const value = (target as unknown as Record<string, unknown>)[prop];
714
+ return wrap(value, sourceSeq, [prop]);
715
+ }
716
+ if (typeof prop === "string" && bodyMethods.has(prop)) {
717
+ const fn = (target as unknown as Record<string, unknown>)[prop] as
718
+ | ((...a: unknown[]) => Promise<unknown>)
719
+ | undefined;
720
+ if (typeof fn !== "function") return fn;
721
+ return async (...args: unknown[]) => {
722
+ const value = await fn.apply(target, args);
723
+ return wrap(value, sourceSeq, ["body"]);
724
+ };
725
+ }
726
+ const value = (target as unknown as Record<string | symbol, unknown>)[
727
+ prop as string | symbol
728
+ ];
729
+ return typeof value === "function"
730
+ ? (value as (...a: unknown[]) => unknown).bind(target)
731
+ : value;
732
+ },
733
+ has(target, prop) {
734
+ if (prop === OP_TAG || prop === UNWRAP) return true;
735
+ return Reflect.has(target, prop);
736
+ },
737
+ };
738
+ return new Proxy(res, handler) as unknown as WrappedResponse;
739
+ }