@specific.dev/spectest 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -0
- package/src/browser.ts +824 -0
- package/src/components/index.ts +32 -0
- package/src/components/k3s.ts +1324 -0
- package/src/components/postgres.ts +281 -0
- package/src/components/replayFake.ts +515 -0
- package/src/daemon.ts +3910 -0
- package/src/index.ts +1601 -0
- package/src/ingress.ts +288 -0
- package/src/inspect.ts +604 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +659 -0
- package/src/resolver.ts +351 -0
- package/src/terminal.ts +740 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +520 -0
- package/src/vendor/rrweb-record.min.js +5 -0
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
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
|
+
export const OP_TAG = Symbol("spectest.opTag");
|
|
48
|
+
export const UNWRAP = Symbol("spectest.unwrap");
|
|
49
|
+
|
|
50
|
+
// The escape hatch from a wrapped value to its raw form is the `.unwrap()`
|
|
51
|
+
// method that lives on the `Carrier` / `WrappedObject` / `WrappedArray` /
|
|
52
|
+
// `WrappedResponse` types. We deliberately do NOT augment the global
|
|
53
|
+
// `Number`/`String`/`Boolean`/`Object` prototypes with a phantom `unwrap()`:
|
|
54
|
+
// that made `.unwrap()` typecheck on *every* value, including a genuinely raw
|
|
55
|
+
// primitive (a third-party return, or a plain `fetch` the project code calls
|
|
56
|
+
// itself), where `.unwrap()` would compile but throw at runtime. Without the
|
|
57
|
+
// phantom, `.unwrap()` only typechecks on a value whose static type is one of
|
|
58
|
+
// the wrapper types — and because spectest ops now wrap unconditionally (see
|
|
59
|
+
// above), those static types are honest at runtime in every context. A value
|
|
60
|
+
// that is raw *and* typed raw (it came from outside a spectest op) neither
|
|
61
|
+
// needs nor offers `.unwrap()` — use it directly.
|
|
62
|
+
|
|
63
|
+
export interface OpTag {
|
|
64
|
+
/** Seq of the timeline event this value came from, or `undefined` when the
|
|
65
|
+
* value was wrapped without a recorded event (no recorder, paused, or the
|
|
66
|
+
* event was truncated). `expect()` only nests under a defined `sourceSeq`. */
|
|
67
|
+
sourceSeq: number | undefined;
|
|
68
|
+
path: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read the tag if present; returns undefined for raw values. */
|
|
72
|
+
export function readTag(x: unknown): OpTag | undefined {
|
|
73
|
+
if (x === null || x === undefined) return undefined;
|
|
74
|
+
if (typeof x !== "object" && typeof x !== "function") return undefined;
|
|
75
|
+
return (x as { [OP_TAG]?: OpTag })[OP_TAG];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
79
|
+
// Pending nullish-leaf register
|
|
80
|
+
//
|
|
81
|
+
// Reading a `null`/`undefined` leaf off a wrapped object (`dep.status.readyReplicas`
|
|
82
|
+
// on a failed deployment) hands back a raw nullish value with no tag — a symbol
|
|
83
|
+
// can't ride on `null`/`undefined`, and minting a stand-in object would break
|
|
84
|
+
// every `=== null` / `if (!x)` in real code (see `wrap`). So the natural
|
|
85
|
+
// `expect(dep.status.readyReplicas).toBeFalsy()` used to lose its provenance and
|
|
86
|
+
// render as a disconnected top-level assertion, even though `field(...)` could
|
|
87
|
+
// recover it manually.
|
|
88
|
+
//
|
|
89
|
+
// To make the natural form work too, the proxy *notes* every nullish leaf read
|
|
90
|
+
// here (source op + access path). `expect()` consults the register when it
|
|
91
|
+
// receives an untagged nullish value via `adoptNullishTag`, matching the most
|
|
92
|
+
// recent note. The note is single-consume, freshest-wins, and invalidated by
|
|
93
|
+
// any subsequent recorded op (the recorder calls `clearPendingNullish`), so the
|
|
94
|
+
// window for misattribution is one untagged nullish read immediately followed by
|
|
95
|
+
// an `expect` of an unrelated nullish literal with no op in between — narrow,
|
|
96
|
+
// and the worst case points at a contextually-adjacent field rather than losing
|
|
97
|
+
// the link entirely. The note never reaches program control flow: it is read
|
|
98
|
+
// only inside `expect()`, which `readRaw`s back to the real nullish value.
|
|
99
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
let pendingNullish: { value: null | undefined; tag: OpTag } | undefined;
|
|
102
|
+
|
|
103
|
+
function noteNullishLeaf(
|
|
104
|
+
value: null | undefined,
|
|
105
|
+
sourceSeq: number | undefined,
|
|
106
|
+
path: readonly string[],
|
|
107
|
+
): void {
|
|
108
|
+
pendingNullish = { value, tag: { sourceSeq, path: [...path] } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Forget any pending nullish-leaf note. Called by the recorder whenever a new
|
|
112
|
+
* op is recorded, so a note can't outlive the read that produced it. */
|
|
113
|
+
export function clearPendingNullish(): void {
|
|
114
|
+
pendingNullish = undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* If `value` is an untagged `null`/`undefined` that matches the most recent
|
|
119
|
+
* nullish-leaf read, mint a tagged {@link makeCarrier} holder for it (the same
|
|
120
|
+
* stand-in `field`/`retag` use) and clear the note; otherwise return `value`
|
|
121
|
+
* unchanged. The holder is safe because it goes straight to `expect()`, which
|
|
122
|
+
* `readRaw`s it before matching — it never escapes into control flow.
|
|
123
|
+
*/
|
|
124
|
+
export function adoptNullishTag<T>(value: T): T {
|
|
125
|
+
if (value !== null && value !== undefined) return value;
|
|
126
|
+
const p = pendingNullish;
|
|
127
|
+
if (!p || !Object.is(p.value, value)) return value;
|
|
128
|
+
pendingNullish = undefined;
|
|
129
|
+
return makeCarrier(value, p.tag.sourceSeq, p.tag.path) as unknown as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** If x is wrapped, return the raw value; otherwise return x. */
|
|
133
|
+
export function readRaw<T>(x: T): T {
|
|
134
|
+
// Walks the chain — a value can carry more than one wrapper at once
|
|
135
|
+
// (e.g. a polled k8s pod is wrapped by the component's `withTagging`
|
|
136
|
+
// with the HTTP seq, then by `ctx.poll`'s `wrap(...)` with the wait
|
|
137
|
+
// seq). A single-step unwrap would still hand callers a proxy.
|
|
138
|
+
let cur: unknown = x;
|
|
139
|
+
while (cur !== null && cur !== undefined) {
|
|
140
|
+
const t = typeof cur;
|
|
141
|
+
if (t !== "object" && t !== "function") return cur as T;
|
|
142
|
+
// Presence check, NOT `next === undefined`: a carrier minted by `field`
|
|
143
|
+
// may wrap `undefined` itself (`[UNWRAP] = undefined`). Testing the value
|
|
144
|
+
// would mistake that for "no wrapper" and hand the carrier object back, so
|
|
145
|
+
// a `toBeFalsy()` would see a truthy object.
|
|
146
|
+
if (!(UNWRAP in (cur as object))) return cur as T;
|
|
147
|
+
const next = (cur as { [UNWRAP]?: unknown })[UNWRAP];
|
|
148
|
+
if (next === cur) return cur as T;
|
|
149
|
+
cur = next;
|
|
150
|
+
}
|
|
151
|
+
return cur as T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The raw type behind a wrapper: a `Carrier`/`WrappedObject`/`WrappedArray`/
|
|
155
|
+
* `WrappedResponse` resolves to its `unwrap()` return type; anything else is
|
|
156
|
+
* already raw and passes through unchanged. */
|
|
157
|
+
export type Unwrap<T> = T extends { unwrap(): infer V } ? V : T;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wrap a value so reads through it carry an `OpTag`. Recursion is lazy:
|
|
161
|
+
* a property read on an object Proxy wraps its child on demand.
|
|
162
|
+
*/
|
|
163
|
+
export function wrap<T>(
|
|
164
|
+
raw: T,
|
|
165
|
+
sourceSeq: number | undefined,
|
|
166
|
+
path: readonly string[] = [],
|
|
167
|
+
): T {
|
|
168
|
+
if (raw === null || raw === undefined) return raw;
|
|
169
|
+
const t = typeof raw;
|
|
170
|
+
if (t === "object") {
|
|
171
|
+
return wrapObject(raw as object, sourceSeq, path) as unknown as T;
|
|
172
|
+
}
|
|
173
|
+
if (t === "function") return raw;
|
|
174
|
+
// primitive
|
|
175
|
+
return makeCarrier(raw, sourceSeq, path) as unknown as T;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Like {@link wrap}, but for values read *through* a wrapped container, where a
|
|
180
|
+
* `null`/`undefined` leaf must still leave a provenance trail. `wrap` passes
|
|
181
|
+
* nullish through raw (a symbol can't ride on it); here we additionally
|
|
182
|
+
* {@link noteNullishLeaf note} the read so `expect()` can adopt the tag via
|
|
183
|
+
* {@link adoptNullishTag}. The returned value is identical to `wrap`'s — raw
|
|
184
|
+
* nullish or a proxy/carrier — so control flow is unaffected.
|
|
185
|
+
*/
|
|
186
|
+
function wrapChild<T>(value: T, sourceSeq: number | undefined, path: readonly string[]): T {
|
|
187
|
+
if (value === null || value === undefined) {
|
|
188
|
+
noteNullishLeaf(value as null | undefined, sourceSeq, path);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
return wrap(value, sourceSeq, path);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Array methods whose return value derives from the array's contents.
|
|
195
|
+
// We re-wrap their results so assertions on `arr.find(...)` etc. still
|
|
196
|
+
// fold under the originating op. Predicates still see raw items (the
|
|
197
|
+
// method runs on the raw target), so `===` comparisons keep working.
|
|
198
|
+
const ARRAY_TAGGED_METHODS = new Set([
|
|
199
|
+
"find",
|
|
200
|
+
"findLast",
|
|
201
|
+
"at",
|
|
202
|
+
"map",
|
|
203
|
+
"filter",
|
|
204
|
+
"slice",
|
|
205
|
+
"flat",
|
|
206
|
+
"flatMap",
|
|
207
|
+
"concat",
|
|
208
|
+
"includes",
|
|
209
|
+
"indexOf",
|
|
210
|
+
"lastIndexOf",
|
|
211
|
+
"findIndex",
|
|
212
|
+
"findLastIndex",
|
|
213
|
+
"some",
|
|
214
|
+
"every",
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
function wrapObject<T extends object>(
|
|
218
|
+
raw: T,
|
|
219
|
+
sourceSeq: number | undefined,
|
|
220
|
+
path: readonly string[],
|
|
221
|
+
): T {
|
|
222
|
+
const tag: OpTag = { sourceSeq, path };
|
|
223
|
+
const isArray = Array.isArray(raw);
|
|
224
|
+
const handler: ProxyHandler<T> = {
|
|
225
|
+
get(target, prop, receiver) {
|
|
226
|
+
if (prop === OP_TAG) return tag;
|
|
227
|
+
if (prop === UNWRAP) return target;
|
|
228
|
+
if (prop === "unwrap") {
|
|
229
|
+
// Only intercept when the underlying object doesn't already have
|
|
230
|
+
// its own `unwrap` — otherwise we'd shadow a legitimate property.
|
|
231
|
+
// `readRaw` (not a bare `target`) so a value carrying more than one
|
|
232
|
+
// wrapper — e.g. a `ctx.poll` result whose inner value was already
|
|
233
|
+
// tagged by a component's `withTagging` — unwraps all the way to raw
|
|
234
|
+
// in one call, not just one layer.
|
|
235
|
+
if (!(prop in (target as object))) {
|
|
236
|
+
return () => readRaw(target);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Hide thenable-ness from `await`. We must never accidentally
|
|
240
|
+
// implement `then`, or `await fetch(...)` would resolve to the
|
|
241
|
+
// wrong thing if we ever wrapped a Promise (we don't, but be safe).
|
|
242
|
+
if (prop === "then" && !(prop in (target as object))) return undefined;
|
|
243
|
+
|
|
244
|
+
const value = Reflect.get(target, prop, receiver);
|
|
245
|
+
if (typeof prop === "symbol") {
|
|
246
|
+
// Symbols (iterator, toPrimitive, etc.) — pass through bound.
|
|
247
|
+
return typeof value === "function"
|
|
248
|
+
? (value as (...a: unknown[]) => unknown).bind(target)
|
|
249
|
+
: value;
|
|
250
|
+
}
|
|
251
|
+
// Container shape stays raw: `rows.length === 1` must be a plain
|
|
252
|
+
// number comparison, not carrier-vs-number (silently false).
|
|
253
|
+
// Provenance for "how many" assertions still works — `expect`
|
|
254
|
+
// accepts raw values, it just won't nest under the op.
|
|
255
|
+
if (isArray && prop === "length") return value;
|
|
256
|
+
const childPath = path.concat(String(prop));
|
|
257
|
+
if (typeof value === "function") {
|
|
258
|
+
if (isArray && ARRAY_TAGGED_METHODS.has(prop)) {
|
|
259
|
+
const method = value as (...a: unknown[]) => unknown;
|
|
260
|
+
const taggedPath = path.concat(`<${prop}>`);
|
|
261
|
+
return (...args: unknown[]) => {
|
|
262
|
+
const result = method.apply(target, args);
|
|
263
|
+
return wrapChild(result, sourceSeq, taggedPath);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// Other methods stay bound to the raw target; their return
|
|
267
|
+
// values aren't wrapped here — call sites that need
|
|
268
|
+
// promise-returning methods (e.g. Response.json()) install a
|
|
269
|
+
// bespoke wrapper.
|
|
270
|
+
return (value as (...a: unknown[]) => unknown).bind(target);
|
|
271
|
+
}
|
|
272
|
+
return wrapChild(value, sourceSeq, childPath);
|
|
273
|
+
},
|
|
274
|
+
has(target, prop) {
|
|
275
|
+
if (prop === OP_TAG || prop === UNWRAP) return true;
|
|
276
|
+
return Reflect.has(target, prop);
|
|
277
|
+
},
|
|
278
|
+
// Block writes; wrappers are read-only views.
|
|
279
|
+
set() {
|
|
280
|
+
return false;
|
|
281
|
+
},
|
|
282
|
+
deleteProperty() {
|
|
283
|
+
return false;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
return new Proxy(raw, handler);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Runtime shape of a primitive carrier: the public `Carrier<T>` surface
|
|
290
|
+
// (`unwrap()`) plus the internal provenance symbols. Kept private so the
|
|
291
|
+
// exported `Carrier<T>` stays clean.
|
|
292
|
+
interface CarrierCell<T> extends Carrier<T> {
|
|
293
|
+
[OP_TAG]: OpTag;
|
|
294
|
+
[UNWRAP]: T;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function makeCarrier<T>(
|
|
298
|
+
raw: T,
|
|
299
|
+
sourceSeq: number | undefined,
|
|
300
|
+
path: readonly string[],
|
|
301
|
+
): CarrierCell<T> {
|
|
302
|
+
const tag: OpTag = { sourceSeq, path };
|
|
303
|
+
return {
|
|
304
|
+
[OP_TAG]: tag,
|
|
305
|
+
[UNWRAP]: raw,
|
|
306
|
+
unwrap: () => raw,
|
|
307
|
+
// Coercion sinks recover the raw primitive instead of inheriting
|
|
308
|
+
// Object.prototype's defaults ("[object Object]" / NaN / {}). A
|
|
309
|
+
// carrier interpolated into a string, fed to arithmetic, `==`, or
|
|
310
|
+
// `JSON.stringify` now behaves like its value — silent wrongness
|
|
311
|
+
// (a bogus "[object Object]" namespace in a kubectl command) was
|
|
312
|
+
// the most expensive failure mode this wrapper ever produced.
|
|
313
|
+
valueOf: () => raw,
|
|
314
|
+
toString: () => String(raw),
|
|
315
|
+
toJSON: () => raw,
|
|
316
|
+
[Symbol.toPrimitive]: () => raw,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
321
|
+
// Provenance-preserving field selection
|
|
322
|
+
//
|
|
323
|
+
// The proxy/carrier only carries the OpTag across *property reads* (and a
|
|
324
|
+
// whitelist of array methods), and it can't carry it across `null`/`undefined`
|
|
325
|
+
// at all — those are primitives with nowhere to hang a symbol, and `wrap`
|
|
326
|
+
// passes them through raw. So `expect(row.deleted_at).toBe(null)` reaches
|
|
327
|
+
// `expect()` as a bare `null` with no tag and renders as a disconnected
|
|
328
|
+
// top-level row. `field` recovers the link by tagging from the *container*.
|
|
329
|
+
// (Base64-decoded values lose their tag the same way, via the transform; that
|
|
330
|
+
// case is handled by the `.base64Decoded()` provenance transform in index.ts,
|
|
331
|
+
// which decodes the still-tagged value internally and rebuilds the Expectation
|
|
332
|
+
// against the result.)
|
|
333
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Re-tag a value against an existing source op. Uses the normal `wrap` for
|
|
337
|
+
* non-nullish results (object → proxy, primitive → carrier) but a
|
|
338
|
+
* {@link makeCarrier} *holder* for `null`/`undefined` — those can't hold a
|
|
339
|
+
* symbol, and `wrap` deliberately passes them through raw. The holder is an
|
|
340
|
+
* object masquerading as null/undefined, which is ONLY safe because it never
|
|
341
|
+
* escapes into program control flow: it goes straight to `expect()`, which
|
|
342
|
+
* `readRaw`s it back to the real value before matching.
|
|
343
|
+
*/
|
|
344
|
+
function retag(raw: unknown, sourceSeq: number | undefined, path: readonly string[]): unknown {
|
|
345
|
+
return raw === null || raw === undefined
|
|
346
|
+
? makeCarrier(raw, sourceSeq, path)
|
|
347
|
+
: wrap(raw, sourceSeq, path);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Provenance-preserving, null-safe field selector — for asserting on a leaf
|
|
352
|
+
* that is `null`/`undefined`. Reading such a leaf off a wrapped object hands
|
|
353
|
+
* back a raw `null`/`undefined` (a symbol tag can't ride on those, and minting
|
|
354
|
+
* a stand-in object would break every `=== null` / `if (!x)` in real code), so
|
|
355
|
+
* the raw value alone loses its link to the originating op. `field` reads the
|
|
356
|
+
* tag from the *container* and navigates the raw value, returning a tagged
|
|
357
|
+
* handle even when the leaf is nullish — safe because the handle only ever
|
|
358
|
+
* feeds `expect()`. Pass it straight to `expect(...)`; works with every matcher.
|
|
359
|
+
*
|
|
360
|
+
* Mostly redundant now: the plain `expect(dep.status.readyReplicas).toBeFalsy()`
|
|
361
|
+
* form recovers provenance on its own — the proxy notes each nullish leaf read
|
|
362
|
+
* and `expect` adopts the note (see {@link adoptNullishTag}). Reach for `field`
|
|
363
|
+
* when the nullish read and the `expect` are separated by another recorded op
|
|
364
|
+
* (which clears the note), or to make the navigation explicit.
|
|
365
|
+
*
|
|
366
|
+
* ```ts
|
|
367
|
+
* expect(field(created, "branched_from_environment_id")).toBe(null);
|
|
368
|
+
* expect(field(dep, "status", "readyReplicas")).toBeFalsy();
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
export function field(value: unknown, ...path: Array<string | number>): unknown {
|
|
372
|
+
const tag = readTag(value);
|
|
373
|
+
let cur: unknown = readRaw(value);
|
|
374
|
+
for (const key of path) {
|
|
375
|
+
if (cur === null || cur === undefined) {
|
|
376
|
+
cur = undefined;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
cur = (cur as Record<string | number, unknown>)[key];
|
|
380
|
+
}
|
|
381
|
+
if (!tag) return cur;
|
|
382
|
+
return retag(cur, tag.sourceSeq, [...tag.path, ...path.map(String)]);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A provenance-carrying handle to a value produced by a tracked op
|
|
387
|
+
* (`ctx.fetch`, a db query, `browser.evaluate`). Pass it straight to
|
|
388
|
+
* `expect(...)` — the matcher reads the provenance and nests the
|
|
389
|
+
* assertion under the originating op in the timeline.
|
|
390
|
+
*
|
|
391
|
+
* At runtime, coercion sinks recover the raw value (`` `${carrier}` ``,
|
|
392
|
+
* `JSON.stringify(carrier)` behave as if raw). But the *type* is honestly an
|
|
393
|
+
* object, not `T`, so arithmetic (`carrier + 1`), `==`/`===`, and handing it
|
|
394
|
+
* to a typed API are all compile errors — `carrier.unwrap()` (or `expect(...)`,
|
|
395
|
+
* which unwraps for you) first. This is deliberate: a wrapped value is a
|
|
396
|
+
* handle, and the type says so rather than masquerading as its raw type.
|
|
397
|
+
*/
|
|
398
|
+
export interface Carrier<T> {
|
|
399
|
+
/** Recover the raw underlying value. */
|
|
400
|
+
unwrap(): T;
|
|
401
|
+
/** Coerces to the raw value (arithmetic, `==`). */
|
|
402
|
+
valueOf(): T;
|
|
403
|
+
/** Renders the raw value (template interpolation). */
|
|
404
|
+
toString(): string;
|
|
405
|
+
/** Serializes as the raw value under `JSON.stringify`. */
|
|
406
|
+
toJSON(): T;
|
|
407
|
+
/** Coerces to the raw value. */
|
|
408
|
+
[Symbol.toPrimitive](hint?: string): T;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The values `expect()` accepts: anything carrying provenance from a recorded
|
|
413
|
+
* op (fetch / db / exec / browser / fakes / k8s) — every member of the
|
|
414
|
+
* {@link wrap} family ({@link Carrier}, {@link WrappedObject},
|
|
415
|
+
* {@link WrappedArray}, {@link WrappedResponse}) exposes `.unwrap()`, so this
|
|
416
|
+
* structural shape admits them all and rejects a raw primitive / object.
|
|
417
|
+
*
|
|
418
|
+
* `null`/`undefined` are also admitted: a nullish leaf can't carry the symbol
|
|
419
|
+
* tag, but `adoptNullishTag` recovers its provenance at runtime, so
|
|
420
|
+
* `expect(rows[0]?.text)` and `expect(dep.status.readyReplicas)` stay on
|
|
421
|
+
* `expect`. To assert on a value with no provenance (a computed number, a raw
|
|
422
|
+
* WebSocket frame), use `expectRaw(message, value)` instead.
|
|
423
|
+
*/
|
|
424
|
+
export type Provenanced = { unwrap(): unknown } | null | undefined;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* What a tracked op's value looks like once wrapped — applied *deeply*, so the
|
|
428
|
+
* type matches the runtime at every level (the proxy lazily wraps each leaf you
|
|
429
|
+
* read). A primitive becomes a {@link Carrier}; an object keeps its keys but
|
|
430
|
+
* each property is itself `Wrapped`, plus an `.unwrap()`; an array keeps a raw
|
|
431
|
+
* `.length` and indexes to `Wrapped` elements (see {@link WrappedArray}).
|
|
432
|
+
*
|
|
433
|
+
* Because leaves are `Carrier`s rather than their raw type, using one as raw
|
|
434
|
+
* data — arithmetic, string methods, a typed client — is a compile error;
|
|
435
|
+
* `x.unwrap()` (or `expect(x)`, which unwraps) recovers the raw value. That's
|
|
436
|
+
* the whole point: the type tells you it's a handle instead of pretending to be
|
|
437
|
+
* the underlying value and blowing up at runtime.
|
|
438
|
+
*
|
|
439
|
+
* **Distributes over unions** so the nullish members of an optional property
|
|
440
|
+
* survive as `null`/`undefined` rather than collapsing into a `Carrier<undefined>`.
|
|
441
|
+
* That's what lets `?.` narrow a wrapped optional leaf: `Wrapped<V1PodStatus |
|
|
442
|
+
* undefined>` is `WrappedObject<V1PodStatus> | undefined` (so `pod.status?.phase`
|
|
443
|
+
* type-checks), not `Carrier<undefined> | WrappedObject<…>` (where `?.` can't see
|
|
444
|
+
* the `Carrier` as nullish). A nullish leaf still reaches `expect` untagged and is
|
|
445
|
+
* recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
|
|
446
|
+
* it includes `null | undefined`.
|
|
447
|
+
*/
|
|
448
|
+
export type Wrapped<T> = T extends null | undefined
|
|
449
|
+
? T
|
|
450
|
+
: T extends readonly (infer U)[]
|
|
451
|
+
? WrappedArray<U>
|
|
452
|
+
: // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
453
|
+
T extends (...args: never[]) => unknown
|
|
454
|
+
? T
|
|
455
|
+
: T extends object
|
|
456
|
+
? WrappedObject<T>
|
|
457
|
+
: Carrier<T>;
|
|
458
|
+
|
|
459
|
+
/** A wrapped object: every own property is itself {@link Wrapped}, plus an
|
|
460
|
+
* `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
|
|
461
|
+
export type WrappedObject<T> = {
|
|
462
|
+
readonly [K in keyof T]: Wrapped<T[K]>;
|
|
463
|
+
} & {
|
|
464
|
+
/** Recover the fully raw value (nested leaves unwrapped too). */
|
|
465
|
+
unwrap(): T;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A wrapped array. Indexing and the content-deriving methods (`find`, `map`,
|
|
470
|
+
* `filter`, `slice`, …) return {@link Wrapped} values so assertions on them
|
|
471
|
+
* still fold under the originating op; their *predicates* receive raw elements
|
|
472
|
+
* (the method runs on the raw target), so `=== ` comparisons inside a predicate
|
|
473
|
+
* keep working. `.length` stays a real `number` — provenance belongs on the
|
|
474
|
+
* data, not the container's size, so `rows.length === 1` must be a plain
|
|
475
|
+
* comparison. Iteration (`for…of`, spread) yields raw elements.
|
|
476
|
+
*/
|
|
477
|
+
export interface WrappedArray<U> {
|
|
478
|
+
readonly length: number;
|
|
479
|
+
readonly [index: number]: Wrapped<U>;
|
|
480
|
+
/** Recover the raw array (elements unwrapped). */
|
|
481
|
+
unwrap(): U[];
|
|
482
|
+
at(index: number): Wrapped<U> | undefined;
|
|
483
|
+
find(
|
|
484
|
+
predicate: (value: U, index: number, obj: U[]) => unknown,
|
|
485
|
+
): Wrapped<U> | undefined;
|
|
486
|
+
findLast(
|
|
487
|
+
predicate: (value: U, index: number, obj: U[]) => unknown,
|
|
488
|
+
): Wrapped<U> | undefined;
|
|
489
|
+
findIndex(predicate: (value: U, index: number, obj: U[]) => unknown): number;
|
|
490
|
+
findLastIndex(
|
|
491
|
+
predicate: (value: U, index: number, obj: U[]) => unknown,
|
|
492
|
+
): number;
|
|
493
|
+
filter(
|
|
494
|
+
predicate: (value: U, index: number, array: U[]) => unknown,
|
|
495
|
+
): WrappedArray<U>;
|
|
496
|
+
map<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
|
|
497
|
+
slice(start?: number, end?: number): WrappedArray<U>;
|
|
498
|
+
concat(...items: U[][]): WrappedArray<U>;
|
|
499
|
+
flat(): WrappedArray<unknown>;
|
|
500
|
+
flatMap<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
|
|
501
|
+
/** Note: returns a `Carrier<boolean>` at runtime (re-wrapped for provenance),
|
|
502
|
+
* so use `arr.includes(x).unwrap()` or `expect(arr.includes(x))` rather than
|
|
503
|
+
* a bare `if`. */
|
|
504
|
+
includes(value: U, fromIndex?: number): Carrier<boolean>;
|
|
505
|
+
indexOf(value: U, fromIndex?: number): Carrier<number>;
|
|
506
|
+
lastIndexOf(value: U, fromIndex?: number): Carrier<number>;
|
|
507
|
+
some(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
|
|
508
|
+
every(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
|
|
509
|
+
/** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
|
|
510
|
+
[Symbol.iterator](): IterableIterator<U>;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The view `ctx.fetch` resolves to in every context (a spectest op wraps
|
|
515
|
+
* unconditionally): a {@link Response} whose status-line accessors are
|
|
516
|
+
* {@link Carrier}s — so a raw `res.status === 200` is a *type error*
|
|
517
|
+
* (status is a `Carrier<number>`, not a number), the exact mistake that
|
|
518
|
+
* used to silently always be false. Compare `res.status.unwrap() === 200`
|
|
519
|
+
* or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
|
|
520
|
+
*
|
|
521
|
+
* `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
|
|
522
|
+
* (or {@link unwrap the whole response}) recovers the plain `Response`.
|
|
523
|
+
*/
|
|
524
|
+
export interface WrappedResponse {
|
|
525
|
+
readonly status: Carrier<number>;
|
|
526
|
+
readonly ok: Carrier<boolean>;
|
|
527
|
+
readonly statusText: Carrier<string>;
|
|
528
|
+
readonly url: Carrier<string>;
|
|
529
|
+
readonly redirected: Carrier<boolean>;
|
|
530
|
+
readonly type: Carrier<string>;
|
|
531
|
+
readonly headers: Headers;
|
|
532
|
+
readonly bodyUsed: boolean;
|
|
533
|
+
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
534
|
+
text(): Promise<Carrier<string>>;
|
|
535
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
536
|
+
blob(): Promise<Blob>;
|
|
537
|
+
formData(): Promise<FormData>;
|
|
538
|
+
clone(): WrappedResponse;
|
|
539
|
+
/** Recover the underlying raw {@link Response} (a real `number` status,
|
|
540
|
+
* an unwrapped body, etc.). */
|
|
541
|
+
unwrap(): Response;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** The signature of `ctx.fetch`: a `fetch` that resolves to a
|
|
545
|
+
* {@link WrappedResponse} so reads carry provenance into assertions. */
|
|
546
|
+
export type SpectestFetch = (
|
|
547
|
+
input: RequestInfo | URL,
|
|
548
|
+
init?: RequestInit,
|
|
549
|
+
) => Promise<WrappedResponse>;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
553
|
+
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
554
|
+
* to `sourceSeq`. The body-reading methods (`json`, `text`) return the
|
|
555
|
+
* resolved value wrapped under `path: ["body"]`. Everything else passes
|
|
556
|
+
* through bound to the real Response.
|
|
557
|
+
*/
|
|
558
|
+
export function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse {
|
|
559
|
+
const tag: OpTag = { sourceSeq, path: [] };
|
|
560
|
+
const carrierProps = new Set([
|
|
561
|
+
"status",
|
|
562
|
+
"ok",
|
|
563
|
+
"statusText",
|
|
564
|
+
"url",
|
|
565
|
+
"redirected",
|
|
566
|
+
"type",
|
|
567
|
+
]);
|
|
568
|
+
const bodyMethods = new Set(["json", "text"]);
|
|
569
|
+
const handler: ProxyHandler<Response> = {
|
|
570
|
+
get(target, prop) {
|
|
571
|
+
if (prop === OP_TAG) return tag;
|
|
572
|
+
if (prop === UNWRAP) return target;
|
|
573
|
+
// Full recursive unwrap (see the note in `wrapObject`).
|
|
574
|
+
if (prop === "unwrap") return () => readRaw(target);
|
|
575
|
+
if (prop === "then") return undefined;
|
|
576
|
+
|
|
577
|
+
if (typeof prop === "string" && carrierProps.has(prop)) {
|
|
578
|
+
const value = (target as unknown as Record<string, unknown>)[prop];
|
|
579
|
+
return wrap(value, sourceSeq, [prop]);
|
|
580
|
+
}
|
|
581
|
+
if (typeof prop === "string" && bodyMethods.has(prop)) {
|
|
582
|
+
const fn = (target as unknown as Record<string, unknown>)[prop] as
|
|
583
|
+
| ((...a: unknown[]) => Promise<unknown>)
|
|
584
|
+
| undefined;
|
|
585
|
+
if (typeof fn !== "function") return fn;
|
|
586
|
+
return async (...args: unknown[]) => {
|
|
587
|
+
const value = await fn.apply(target, args);
|
|
588
|
+
return wrap(value, sourceSeq, ["body"]);
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const value = (target as unknown as Record<string | symbol, unknown>)[
|
|
592
|
+
prop as string | symbol
|
|
593
|
+
];
|
|
594
|
+
return typeof value === "function"
|
|
595
|
+
? (value as (...a: unknown[]) => unknown).bind(target)
|
|
596
|
+
: value;
|
|
597
|
+
},
|
|
598
|
+
has(target, prop) {
|
|
599
|
+
if (prop === OP_TAG || prop === UNWRAP) return true;
|
|
600
|
+
return Reflect.has(target, prop);
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
return new Proxy(res, handler) as unknown as WrappedResponse;
|
|
604
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Eval-scoped secret channel for record-mode fakes (see
|
|
2
|
+
// `components/replayFake.ts`).
|
|
3
|
+
//
|
|
4
|
+
// The control plane resolves a platform secret (e.g. `STRIPE_API_KEY`)
|
|
5
|
+
// server-side and pushes it into the daemon on the `/eval` request body
|
|
6
|
+
// only — never on the `run_tests` path, never into the project tarball,
|
|
7
|
+
// the config hash, or any cassette. The daemon stashes the resolved
|
|
8
|
+
// values here for exactly the duration of one eval (set before the
|
|
9
|
+
// snippet runs, cleared in the eval's `finally`) and the record-mode fake
|
|
10
|
+
// forwarder reads them via {@link getRecordSecret}.
|
|
11
|
+
//
|
|
12
|
+
// Eval-scoped is load-bearing: daemon memory survives snapshot/fork, so a
|
|
13
|
+
// long-lived secrets map could ride a warm/post-test snapshot into a
|
|
14
|
+
// forkable state a hermetic `spectest test` could observe. Clearing per
|
|
15
|
+
// eval means a secret never persists into anything a replay run can reach.
|
|
16
|
+
// This module is shared (one instance per daemon process) so the daemon
|
|
17
|
+
// writes and the component reads the same map.
|
|
18
|
+
|
|
19
|
+
const SECRETS = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
/** Replace the eval-scoped secret set. Called by the daemon at the start
|
|
22
|
+
* of each `/eval` with the values the control plane resolved. */
|
|
23
|
+
export function setRecordSecrets(secrets: Record<string, string> | undefined): void {
|
|
24
|
+
SECRETS.clear();
|
|
25
|
+
if (!secrets) return;
|
|
26
|
+
for (const [ref, value] of Object.entries(secrets)) {
|
|
27
|
+
if (typeof value === "string") SECRETS.set(ref, value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Drop all secrets. Called from the eval's `finally` so nothing survives
|
|
32
|
+
* past the eval that supplied them. */
|
|
33
|
+
export function clearRecordSecrets(): void {
|
|
34
|
+
SECRETS.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a secret by its platform `ref`. `undefined` if the control
|
|
38
|
+
* plane didn't supply it (ref not configured, or not on the eval path). */
|
|
39
|
+
export function getRecordSecret(ref: string): string | undefined {
|
|
40
|
+
return SECRETS.get(ref);
|
|
41
|
+
}
|