@specific.dev/spectest 0.88.3 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,42 @@ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/lat
15
15
  and run `spectest docs /installation` for project setup and authentication.
16
16
  `spectest docs` contains the complete offline authoring guide.
17
17
 
18
+ ## HTTP assertions
19
+
20
+ `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
21
+ all body readers retain the HTTP call's provenance, so assertions appear under
22
+ that call on the timeline:
23
+
24
+ ```ts
25
+ const response = await ctx.fetch("http://app:3000/image.png");
26
+ expect(response.status).toBe(200);
27
+ expect(response.headers.get("content-type")).toBe("image/png");
28
+ const bytes = await response.bytes();
29
+ expect(bytes).toHaveLength(70);
30
+ expect(bytes[0]).toBe(0x89);
31
+ expect(bytes.slice(0, 8)).toEqual(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10]));
32
+ ```
33
+
34
+ `arrayBuffer()` wraps buffer metadata and slices. `blob()` wraps metadata and
35
+ its body readers. `formData()` wraps `get()`, `getAll()`, and `has()`, including
36
+ uploaded files and their metadata. Headers also support wrapped `getSetCookie()`
37
+ and Bun's `getAll()`, `toJSON()`, and `count`. Missing fields remain `null`, with
38
+ provenance recovered by an immediately following `expect`.
39
+
40
+ Use `.unwrap()` to pass a wrapped value to a native API, such as
41
+ `new TextDecoder().decode(bytes.unwrap())`. Use `.transform("text", bytes =>
42
+ new TextDecoder().decode(bytes))` when the derived value should retain provenance.
43
+ Iteration and callback arguments remain raw, as do array/typed-array `length`
44
+ and stream control flags (`done`, `locked`). Use `expect(bytes).toHaveLength(n)`
45
+ for a linked length assertion. `bodyUsed` is wrapped: unwrap it for control flow.
46
+
47
+ For an open-ended stream, pass `{ captureBody: false }` to return without waiting
48
+ for recorder body capture. SSE (`text/event-stream`) skips capture automatically.
49
+ The HTTP event and assertions are still recorded; the event omits the body.
50
+ `response.body.getReader().read()` returns a raw `done` flag and wrapped `value`.
51
+ BYOB readers, `tee()`, and `pipeThrough()` preserve provenance too. Release reader
52
+ locks and cancel streams when finished, as with native fetch.
53
+
18
54
  ## Electron apps
19
55
 
20
56
  Use `electron()` to build and run a real Linux Electron app under Xvfb, with
@@ -54,11 +90,15 @@ interactions and replays the app with window chrome derived from its Electron
54
90
  configuration. Child tests using `dependsOn` inherit the live app state,
55
91
  including unsaved input, in their own isolated environment.
56
92
 
57
- The app must declare Electron in `package.json`. Defaults are `npm ci` with a
93
+ The app must declare Electron in `package.json` (its own or, with `context`
94
+ set to a monorepo root, the root one). Defaults are `npm ci` with a
58
95
  package lock (otherwise `npm install`), `npm run build`, and the package's
59
96
  `main` entry. Use `appDir`, `installCommand`, `buildCommand`, `entry`, and
60
97
  `nodeVersion` to customize the build. `env` supplies runtime variables;
61
- `dependsOn` waits for backend services in the same environment.
98
+ `dependsOn` waits for backend services in the same environment. Expose the
99
+ backend through the built-in proxy (`tls: [{ hostname, port }]`) and give the
100
+ app its `https://` URL: a renderer Content Security Policy usually refuses a
101
+ plain `http://` service address.
62
102
 
63
103
  Applications need Linux-compatible dependencies and binaries. Native OS dialogs
64
104
  and macOS-specific APIs are outside this basic support. Replays capture the
@@ -1 +1,2 @@
1
+ export declare const ELECTRON_CA_TRUST: string;
1
2
  export declare const ELECTRON_CHROME_HOOK: string;
@@ -1,7 +1,54 @@
1
+ // Trust for the environment CA, applied per session. Chromium on Linux
2
+ // reads its own root store (NSS), never the container's mounted bundle, so
3
+ // the renderer rejects every certificate the environment mints while Node
4
+ // in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
5
+ // procedure accepts exactly the chains that CA issued for the requested
6
+ // host, and defers to Chromium's own verdict for everything else. An app
7
+ // that installs its own procedure later replaces it, as in production.
8
+ // Kept free of Electron so the unit test can run it under Bun.
9
+ export const ELECTRON_CA_TRUST = String.raw `
10
+ const { X509Certificate } = require('node:crypto');
11
+ function loadEnvironmentCa(path) {
12
+ try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
13
+ catch { return null; }
14
+ }
15
+ // request: { hostname, errorCode, certificate: { data, issuerCert? } }.
16
+ // Returns true only for a chain that ends at the CA and names the host.
17
+ function issuedByEnvironmentCa(ca, request) {
18
+ try {
19
+ const leaf = new X509Certificate(request.certificate.data);
20
+ const host = request.hostname;
21
+ const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
22
+ if (!named) return false;
23
+ const chain = [leaf];
24
+ for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
25
+ chain.push(new X509Certificate(c.issuerCert.data));
26
+ }
27
+ for (let i = 0; i < chain.length; i++) {
28
+ const cert = chain[i];
29
+ if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
30
+ const issuer = chain[i + 1] ?? ca;
31
+ if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
32
+ }
33
+ return true;
34
+ } catch { return false; }
35
+ }
36
+ `;
1
37
  // Runs before the application's main entry, like Playwright's Electron loader.
2
38
  // Observe constructor options; do not replace the app entry, preload, IPC, or OS.
3
39
  export const ELECTRON_CHROME_HOOK = String.raw `
4
40
  const electron = require('electron');
41
+ ${ELECTRON_CA_TRUST}
42
+ const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
43
+ if (environmentCa) {
44
+ // Every session, including the default one and app-created partitions.
45
+ electron.app.on('session-created', session => {
46
+ session.setCertificateVerifyProc((request, callback) => {
47
+ if (request.errorCode === 0) return callback(0);
48
+ callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
49
+ });
50
+ });
51
+ }
5
52
  const NativeBrowserWindow = electron.BrowserWindow;
6
53
  const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
7
54
  construct(Target, args, NewTarget) {
@@ -1,7 +1,16 @@
1
1
  import { type DesktopApp } from "../desktop.js";
2
2
  export interface ElectronOptions {
3
- /** Project-relative app directory; must contain package.json and Electron. */
3
+ /** Project-relative app directory; must contain package.json. Electron must
4
+ * be declared here or in the context's package.json. */
4
5
  appDir?: string;
6
+ /**
7
+ * Image build context, relative to the project root. Default `appDir`.
8
+ * Set it to the repository root when the app is one workspace of a
9
+ * monorepo and resolves packages from above its own directory. The
10
+ * context is copied to `/app`; `installCommand` runs at the context root,
11
+ * `buildCommand` and Electron run in the app directory below it.
12
+ */
13
+ context?: string;
5
14
  /** Debian Node image tag, default 24-bookworm-slim. */
6
15
  nodeVersion?: string;
7
16
  /** Build the main/preload/renderer output; default npm run build. */
@@ -31,6 +40,7 @@ export declare function electron(options?: ElectronOptions): {
31
40
  command: string;
32
41
  env: {
33
42
  SPECTEST_ELECTRON_ENTRY: string;
43
+ SPECTEST_ELECTRON_APP_DIR: string;
34
44
  };
35
45
  dependsOn: string[] | undefined;
36
46
  ports: number[];
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { posix } from "node:path";
2
3
  import { resolveProjectPath } from "../project-files.js";
3
4
  import { desktopApp } from "../desktop.js";
4
5
  import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
@@ -9,13 +10,14 @@ export const ELECTRON_LAUNCHER = String.raw `
9
10
  import http from 'node:http';
10
11
  import { spawn } from 'node:child_process';
11
12
  import { createRequire } from 'node:module';
12
- const require = createRequire('/app/package.json');
13
+ const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
14
+ const require = createRequire(appDir + '/package.json');
13
15
  const child = spawn(require('electron'), [
14
16
  ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
15
17
  '--disable-dev-shm-usage', '--remote-debugging-port=9222',
16
18
  '-r', '/spectest-electron-chrome.cjs',
17
19
  process.env.SPECTEST_ELECTRON_ENTRY || '.',
18
- ], { cwd: '/app', stdio: 'inherit', env: process.env });
20
+ ], { cwd: appDir, stdio: 'inherit', env: process.env });
19
21
  const upstream = req => ({
20
22
  host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
21
23
  headers: { ...req.headers, host: '127.0.0.1:9222' },
@@ -50,19 +52,41 @@ child.on('error', error => { console.error(error); process.exit(1); });
50
52
  child.on('exit', code => process.exit(code ?? 1));
51
53
  for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
52
54
  `;
55
+ /** Read a package.json and tell whether it declares electron. */
56
+ function declaresElectron(manifest) {
57
+ if (!existsSync(manifest))
58
+ return false;
59
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
60
+ return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
61
+ }
62
+ /** The app directory as a path inside the build context: "." or "a/b". */
63
+ function appPathInContext(appDir, context) {
64
+ const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
65
+ if (rel === "")
66
+ return ".";
67
+ if (rel.startsWith("..") || posix.isAbsolute(rel)) {
68
+ throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
69
+ }
70
+ return rel;
71
+ }
53
72
  /** Real Linux Electron under Xvfb, with a generated image and graphical replay.
54
73
  * Like expo(), returns an app handle for the matching context method.
55
74
  * Requires Linux-compatible application dependencies and binaries. */
56
75
  export function electron(options = {}) {
57
76
  const appDir = options.appDir ?? ".";
77
+ const context = options.context ?? appDir;
78
+ const appPath = appPathInContext(appDir, context);
58
79
  const manifest = resolveProjectPath(`${appDir}/package.json`);
59
80
  if (!existsSync(manifest))
60
81
  throw new Error(`electron(): no package.json in ${appDir}`);
61
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
62
- if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
63
- throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
82
+ // A monorepo declares electron in the workspace or at the root; both work
83
+ // because the app resolves `electron` upward from its own directory.
84
+ if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
85
+ throw new Error("electron(): declare electron in the app's dependencies or devDependencies" +
86
+ (context === appDir ? "" : ` (in ${appDir} or in the context ${context})`));
64
87
  }
65
- const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
88
+ // The install runs at the context root, so that is where the lockfile counts.
89
+ const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
66
90
  const node = options.nodeVersion ?? "24-bookworm-slim";
67
91
  if (!/^[\w.-]+$/.test(node))
68
92
  throw new Error("electron(): invalid Node image tag");
@@ -71,18 +95,22 @@ export function electron(options = {}) {
71
95
  if ([install, build].some(command => /[\r\n]/.test(command))) {
72
96
  throw new Error("electron(): build/install commands must be single-line shell commands");
73
97
  }
98
+ // Electron can be hoisted to the context root, so find its install script
99
+ // through Node's resolution from the app directory instead of a fixed path.
100
+ const inApp = appPath === "." ? "" : `cd ${appPath} && `;
101
+ const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
74
102
  return {
75
103
  image: {
76
104
  type: "dockerfile",
77
- context: appDir,
105
+ context,
78
106
  exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
79
107
  content: `FROM node:${node}
80
108
  RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
81
109
  WORKDIR /app
82
110
  COPY . .
83
111
  RUN ${install}
84
- RUN node node_modules/electron/install.js
85
- RUN ${build}
112
+ RUN ${fetchElectron}
113
+ RUN ${inApp}${build}
86
114
  `,
87
115
  },
88
116
  files: [
@@ -90,7 +118,11 @@ RUN ${build}
90
118
  { path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
91
119
  ],
92
120
  command: "xvfb-run -a node /spectest-electron.mjs",
93
- env: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
121
+ env: {
122
+ ...options.env,
123
+ SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
124
+ SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
125
+ },
94
126
  dependsOn: options.dependsOn,
95
127
  ports: [9223],
96
128
  readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
@@ -61,8 +61,7 @@ export function isTransportError(err) {
61
61
  export function installFetchWrapper() {
62
62
  const original = globalThis.fetch;
63
63
  // SDK internals (the MCP client, its OAuth flow) must not see the
64
- // wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
65
- // body to the end, which never finishes for an SSE stream.
64
+ // wrapper: its response properties and body readers return wrapped values.
66
65
  setRawFetch(original);
67
66
  const wrappedFn = async (input, init) => {
68
67
  const scope = currentInstrumentationScope();
@@ -85,7 +84,12 @@ export function installFetchWrapper() {
85
84
  const contentType = res.headers.get("content-type");
86
85
  const contentLength = () => parseContentLength(res.headers.get("content-length"));
87
86
  const textual = isTextualContentType(contentType);
88
- if (textual === false) {
87
+ const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
88
+ if (init?.captureBody === false || streaming) {
89
+ // Return on headers: an open-ended stream must not be drained (or
90
+ // cloned into an unbounded background buffer) by the recorder.
91
+ }
92
+ else if (textual === false) {
89
93
  responseBody = omittedBody("binary", contentType, contentLength());
90
94
  }
91
95
  else {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { strict as nodeAssert } from "node:assert";
2
- export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
2
+ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
3
3
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
4
4
  export { field } from "./inspect.js";
5
5
  export { annotate } from "./annotate.js";
@@ -300,11 +300,15 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
300
300
  /**
301
301
  * Instrumented `fetch`. In a test each call is recorded on the timeline
302
302
  * and resolves to a {@link WrappedResponse}: reads carry provenance so
303
- * `expect(res.status)` / `expect(await res.json())` nest under the HTTP
303
+ * `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
304
+ * `expect(await res.json())` nest under the HTTP
304
305
  * call. Because the status-line accessors are {@link Carrier}s, a raw
305
306
  * `res.status === 200` is a *type error* — use `res.status.unwrap()` /
306
307
  * `res.unwrap().status`, or assert via `expect`. Outside a test the
307
308
  * result is wrapped the same way, just with no timeline to link to.
309
+ * Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
310
+ * and stream reader values also retain provenance. Use `captureBody: false`
311
+ * for open-ended streams; SSE responses skip recorder body capture automatically.
308
312
  *
309
313
  * (The plain global `fetch` is wrapped the same way at runtime but keeps
310
314
  * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
package/dist/inspect.d.ts CHANGED
@@ -163,7 +163,7 @@ export type Provenanced = {
163
163
  * recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
164
164
  * it includes `null | undefined`.
165
165
  */
166
- export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
166
+ export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends ArrayBuffer | SharedArrayBuffer ? WrappedBuffer<T> : T extends ArrayBufferView ? WrappedBinaryView<T> : T extends File ? WrappedFile : T extends Blob ? WrappedBlob : T extends FormData ? WrappedFormData : T extends ReadableStream<infer C> ? WrappedStream<C> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
167
167
  /** A wrapped object: every own property is itself {@link Wrapped}, plus an
168
168
  * `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
169
169
  export type WrappedObject<T> = {
@@ -214,6 +214,69 @@ export interface WrappedArray<U> {
214
214
  /** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
215
215
  [Symbol.iterator](): IterableIterator<U>;
216
216
  }
217
+ /** Native objects need their own views: getters and methods must receive the
218
+ * real native receiver, and method results must be wrapped after they resolve.
219
+ * Iteration, callbacks, and control-flow flags on stream readers remain raw.
220
+ */
221
+ type NativeResult<T> = T extends Promise<infer R> ? Promise<Wrapped<R>> : Wrapped<T>;
222
+ type NativeView<T, K extends keyof T> = Omit<T, K> & {
223
+ readonly [P in K]: T[P] extends (...args: infer A) => infer R ? (...args: A) => NativeResult<R> : Wrapped<T[P]>;
224
+ } & {
225
+ unwrap(): T;
226
+ transform<R>(label: string, fn: (raw: T) => R): Wrapped<R>;
227
+ };
228
+ export type WrappedBuffer<T extends ArrayBuffer | SharedArrayBuffer = ArrayBuffer> = NativeView<T, Extract<keyof T, "byteLength" | "maxByteLength" | "resizable" | "growable" | "detached" | "slice" | "transfer" | "transferToFixedLength">>;
229
+ declare const BINARY_METHOD_NAMES: readonly ["at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast", "findIndex", "findLastIndex", "some", "every", "filter", "map", "join", "fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64", "getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32", "getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64"];
230
+ /** Numeric indexes carry provenance. Like WrappedArray, length and iteration
231
+ * stay raw; callbacks receive native values. Unwrap for native API inputs. */
232
+ export type WrappedBinaryView<T extends ArrayBufferView> = NativeView<T, Extract<keyof T, number | "buffer" | "byteLength" | "byteOffset" | typeof BINARY_METHOD_NAMES[number]>>;
233
+ export type WrappedBytes = WrappedBinaryView<Uint8Array>;
234
+ export type WrappedBlob = NativeView<Blob, "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
235
+ export type WrappedFile = NativeView<File, "name" | "lastModified" | "webkitRelativePath" | "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
236
+ export type WrappedFormData = NativeView<FormData, "get" | "getAll" | "has">;
237
+ export type WrappedReadResult<T> = {
238
+ done: false;
239
+ value: Wrapped<T>;
240
+ } | {
241
+ done: true;
242
+ value: Wrapped<T> | undefined;
243
+ };
244
+ export interface WrappedReader<T> extends Omit<ReadableStreamDefaultReader<T>, "read"> {
245
+ read(): Promise<WrappedReadResult<T>>;
246
+ unwrap(): ReadableStreamDefaultReader<T>;
247
+ }
248
+ export interface WrappedBYOBReader extends Omit<ReadableStreamBYOBReader, "read"> {
249
+ read<T extends ArrayBufferView>(view: T, options?: {
250
+ min?: number;
251
+ }): Promise<WrappedReadResult<T>>;
252
+ unwrap(): ReadableStreamBYOBReader;
253
+ }
254
+ export interface WrappedStream<T> extends Omit<ReadableStream<T>, "getReader" | "tee" | "pipeThrough"> {
255
+ getReader(): WrappedReader<T>;
256
+ getReader(options: {
257
+ mode: "byob";
258
+ }): WrappedBYOBReader;
259
+ getReader(options: ReadableStreamGetReaderOptions): WrappedReader<T> | WrappedBYOBReader;
260
+ tee(): [WrappedStream<T>, WrappedStream<T>];
261
+ pipeThrough<R>(transform: ReadableWritablePair<R, any>, options?: StreamPipeOptions): WrappedStream<R>;
262
+ unwrap(): ReadableStream<T>;
263
+ transform<R>(label: string, fn: (raw: ReadableStream<T>) => R): Wrapped<R>;
264
+ }
265
+ /** Response headers whose lookups retain the originating HTTP call's provenance.
266
+ * Missing headers remain `null`; `expect(headers.get(name)).toBe(null)` still
267
+ * links to the call. Iterators and other native methods return raw values.
268
+ */
269
+ export interface WrappedHeaders extends Omit<Headers, "get" | "has" | "getSetCookie" | "getAll" | "toJSON" | "count"> {
270
+ get(name: string): Carrier<string> | null;
271
+ /** Unwrap before using the result as a condition. */
272
+ has(name: string): Carrier<boolean>;
273
+ getSetCookie(): WrappedArray<string>;
274
+ getAll(name: "set-cookie" | "Set-Cookie"): WrappedArray<string>;
275
+ toJSON(): Wrapped<Record<string, string | string[]>>;
276
+ readonly count: Carrier<number>;
277
+ unwrap(): Headers;
278
+ transform<R = unknown>(label: string, fn: (raw: Headers) => R): Wrapped<R>;
279
+ }
217
280
  /**
218
281
  * The view `ctx.fetch` resolves to in every context (a spectest op wraps
219
282
  * unconditionally): a {@link Response} whose status-line accessors are
@@ -222,8 +285,9 @@ export interface WrappedArray<U> {
222
285
  * used to silently always be false. Compare `res.status.unwrap() === 200`
223
286
  * or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
224
287
  *
225
- * `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
226
- * (or {@link unwrap the whole response}) recovers the plain `Response`.
288
+ * All body readers return provenance-carrying values, including binary bodies,
289
+ * blobs and form data. `.unwrap()` recovers the native response or body value.
290
+ * Stream reader `done` and stream `locked` remain raw control-flow booleans.
227
291
  */
228
292
  export interface WrappedResponse {
229
293
  readonly status: Carrier<number>;
@@ -232,13 +296,15 @@ export interface WrappedResponse {
232
296
  readonly url: Carrier<string>;
233
297
  readonly redirected: Carrier<boolean>;
234
298
  readonly type: Carrier<string>;
235
- readonly headers: Headers;
236
- readonly bodyUsed: boolean;
299
+ readonly headers: WrappedHeaders;
300
+ readonly bodyUsed: Carrier<boolean>;
301
+ readonly body: WrappedStream<Uint8Array> | null;
237
302
  json<T = unknown>(): Promise<Wrapped<T>>;
238
303
  text(): Promise<Carrier<string>>;
239
- arrayBuffer(): Promise<ArrayBuffer>;
240
- blob(): Promise<Blob>;
241
- formData(): Promise<FormData>;
304
+ arrayBuffer(): Promise<WrappedBuffer>;
305
+ bytes(): Promise<WrappedBytes>;
306
+ blob(): Promise<WrappedBlob>;
307
+ formData(): Promise<WrappedFormData>;
242
308
  clone(): WrappedResponse;
243
309
  /** Recover the underlying raw {@link Response} (a real `number` status,
244
310
  * an unwrapped body, etc.). */
@@ -246,12 +312,19 @@ export interface WrappedResponse {
246
312
  }
247
313
  /** The signature of `ctx.fetch`: a `fetch` that resolves to a
248
314
  * {@link WrappedResponse} so reads carry provenance into assertions. */
249
- export type SpectestFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<WrappedResponse>;
315
+ export type SpectestFetch = (input: RequestInfo | URL, init?: SpectestRequestInit) => Promise<WrappedResponse>;
316
+ export interface SpectestRequestInit extends RequestInit {
317
+ /** Skip the recorder's body capture for open-ended streams. SSE skips it
318
+ * automatically. The response and assertions remain recorded. */
319
+ captureBody?: boolean;
320
+ }
250
321
  /**
251
322
  * Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
252
323
  * `statusText` / `url` / `redirected` / `type` return carriers tagged
253
- * to `sourceSeq`. The body-reading methods (`json`, `text`) return the
254
- * resolved value wrapped under `path: ["body"]`. Everything else passes
255
- * through bound to the real Response.
324
+ * to `sourceSeq`. Every body-reading method returns its resolved value
325
+ * wrapped under `path: ["body"]`. Header lookups carry paths
326
+ * under `headers`, and clones retain the same provenance. Everything else
327
+ * passes through bound to the real Response.
256
328
  */
257
329
  export declare function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse;
330
+ export {};
package/dist/inspect.js CHANGED
@@ -210,6 +210,20 @@ export function wrap(raw, sourceSeq, path = []) {
210
210
  return raw;
211
211
  const t = typeof raw;
212
212
  if (t === "object") {
213
+ const native = readRaw(raw);
214
+ if (native instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && native instanceof SharedArrayBuffer)) {
215
+ return wrapNative(native, sourceSeq, path, new Set(["byteLength", "maxByteLength", "resizable", "growable", "detached"]), new Set(["slice", "transfer", "transferToFixedLength"]));
216
+ }
217
+ if (ArrayBuffer.isView(native)) {
218
+ return wrapNative(native, sourceSeq, path, new Set(["buffer", "byteLength", "byteOffset"]), BINARY_METHODS, true);
219
+ }
220
+ if (native instanceof Blob) {
221
+ return wrapNative(native, sourceSeq, path, new Set(["size", "type", "name", "lastModified", "webkitRelativePath"]), new Set(["slice", "text", "bytes", "arrayBuffer", "stream"]));
222
+ }
223
+ if (native instanceof FormData)
224
+ return wrapFormData(native, sourceSeq, path);
225
+ if (native instanceof ReadableStream)
226
+ return wrapStream(native, sourceSeq, path);
213
227
  return wrapObject(raw, sourceSeq, path);
214
228
  }
215
229
  if (t === "function")
@@ -437,15 +451,140 @@ export function field(value, ...path) {
437
451
  return cur;
438
452
  return retag(cur, tag.sourceSeq, [...tag.path, ...path.map(String)]);
439
453
  }
454
+ const BINARY_METHOD_NAMES = [
455
+ "at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast",
456
+ "findIndex", "findLastIndex", "some", "every", "filter", "map", "join",
457
+ "fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64",
458
+ "getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32",
459
+ "getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64",
460
+ ];
461
+ const BINARY_METHODS = new Set(BINARY_METHOD_NAMES);
462
+ function wrapNative(raw, sourceSeq, path, properties, methods, indexes = false) {
463
+ const tag = { sourceSeq, path };
464
+ return new Proxy(raw, {
465
+ get(target, prop) {
466
+ if (prop === OP_TAG)
467
+ return tag;
468
+ if (prop === UNWRAP)
469
+ return target;
470
+ if (prop === "unwrap")
471
+ return () => target;
472
+ if (prop === "transform")
473
+ return makeTransform(target, sourceSeq, path);
474
+ const value = Reflect.get(target, prop, target);
475
+ if (typeof prop === "string") {
476
+ if (properties.has(prop) || (indexes && /^(0|[1-9]\d*)$/.test(prop))) {
477
+ return wrapChild(value, sourceSeq, [...path, prop]);
478
+ }
479
+ if (methods.has(prop) && typeof value === "function") {
480
+ return (...args) => {
481
+ const result = value.apply(target, args);
482
+ const resultPath = [...path, `<${prop}>`];
483
+ return result instanceof Promise
484
+ ? result.then((v) => wrapChild(v, sourceSeq, resultPath))
485
+ : wrapChild(result, sourceSeq, resultPath);
486
+ };
487
+ }
488
+ }
489
+ return typeof value === "function" ? value.bind(target) : value;
490
+ },
491
+ has(target, prop) {
492
+ return prop === OP_TAG || prop === UNWRAP || Reflect.has(target, prop);
493
+ },
494
+ });
495
+ }
496
+ function wrapFormData(raw, sourceSeq, path) {
497
+ const base = wrapNative(raw, sourceSeq, path, new Set(), new Set());
498
+ return new Proxy(base, {
499
+ get(target, prop) {
500
+ if (prop === "get" || prop === "getAll" || prop === "has") {
501
+ return (name) => wrapChild(raw[prop](name), sourceSeq, prop === "get" ? [...path, name] : [...path, name, `<${prop}>`]);
502
+ }
503
+ return Reflect.get(target, prop);
504
+ },
505
+ });
506
+ }
507
+ function wrapStream(raw, sourceSeq, path) {
508
+ const base = wrapNative(raw, sourceSeq, path, new Set(), new Set(["pipeThrough"]));
509
+ return new Proxy(base, {
510
+ get(target, prop) {
511
+ if (prop === "tee")
512
+ return () => raw.tee().map((s, i) => wrapStream(s, sourceSeq, [...path, `<tee>`, String(i)]));
513
+ if (prop === "getReader")
514
+ return (options) => {
515
+ const reader = raw.getReader(options);
516
+ let index = 0;
517
+ return new Proxy(reader, {
518
+ get(r, key) {
519
+ if (key === "unwrap" || key === UNWRAP)
520
+ return key === "unwrap" ? () => r : r;
521
+ if (key === OP_TAG)
522
+ return { sourceSeq, path };
523
+ if (key === "read")
524
+ return async (...args) => {
525
+ const read = r.read;
526
+ const result = await read.apply(r, args);
527
+ const valuePath = [...path, "<read>", String(index++)];
528
+ // done stays a real boolean so normal reader loops terminate.
529
+ return { done: result.done, get value() { return wrapChild(result.value, sourceSeq, valuePath); } };
530
+ };
531
+ const value = Reflect.get(r, key, r);
532
+ return typeof value === "function" ? value.bind(r) : value;
533
+ },
534
+ has(r, key) { return key === UNWRAP || key === OP_TAG || Reflect.has(r, key); },
535
+ });
536
+ };
537
+ return Reflect.get(target, prop);
538
+ },
539
+ });
540
+ }
541
+ function wrapHeaders(headers, sourceSeq) {
542
+ const path = ["headers"];
543
+ const tag = { sourceSeq, path };
544
+ return new Proxy(headers, {
545
+ get(target, prop) {
546
+ if (prop === OP_TAG)
547
+ return tag;
548
+ if (prop === UNWRAP)
549
+ return target;
550
+ if (prop === "unwrap")
551
+ return () => target;
552
+ if (prop === "transform")
553
+ return makeTransform(target, sourceSeq, path);
554
+ if (prop === "get" || prop === "has") {
555
+ return (name) => wrapChild(target[prop](name), sourceSeq, prop === "get" ? [...path, name.toLowerCase()] : [...path, name.toLowerCase(), "<has>"]);
556
+ }
557
+ if (prop === "count")
558
+ return wrap(target.count, sourceSeq, [...path, "count"]);
559
+ if (prop === "getAll")
560
+ return (name) => wrap(target.getAll(name), sourceSeq, [...path, name.toLowerCase()]);
561
+ if (prop === "toJSON")
562
+ return () => wrap(target.toJSON(), sourceSeq, path);
563
+ if (prop === "getSetCookie") {
564
+ return () => wrap(target.getSetCookie(), sourceSeq, [...path, "set-cookie"]);
565
+ }
566
+ const value = Reflect.get(target, prop, target);
567
+ return typeof value === "function" ? value.bind(target) : value;
568
+ },
569
+ has(target, prop) {
570
+ if (prop === OP_TAG || prop === UNWRAP)
571
+ return true;
572
+ return Reflect.has(target, prop);
573
+ },
574
+ });
575
+ }
440
576
  /**
441
577
  * Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
442
578
  * `statusText` / `url` / `redirected` / `type` return carriers tagged
443
- * to `sourceSeq`. The body-reading methods (`json`, `text`) return the
444
- * resolved value wrapped under `path: ["body"]`. Everything else passes
445
- * through bound to the real Response.
579
+ * to `sourceSeq`. Every body-reading method returns its resolved value
580
+ * wrapped under `path: ["body"]`. Header lookups carry paths
581
+ * under `headers`, and clones retain the same provenance. Everything else
582
+ * passes through bound to the real Response.
446
583
  */
447
584
  export function wrapResponse(res, sourceSeq) {
448
585
  const tag = { sourceSeq, path: [] };
586
+ let headers;
587
+ let body;
449
588
  const carrierProps = new Set([
450
589
  "status",
451
590
  "ok",
@@ -453,8 +592,9 @@ export function wrapResponse(res, sourceSeq) {
453
592
  "url",
454
593
  "redirected",
455
594
  "type",
595
+ "bodyUsed",
456
596
  ]);
457
- const bodyMethods = new Set(["json", "text"]);
597
+ const bodyMethods = new Set(["json", "text", "bytes", "arrayBuffer", "blob", "formData"]);
458
598
  const handler = {
459
599
  get(target, prop) {
460
600
  if (prop === OP_TAG)
@@ -466,6 +606,15 @@ export function wrapResponse(res, sourceSeq) {
466
606
  return () => readRaw(target);
467
607
  if (prop === "then")
468
608
  return undefined;
609
+ if (prop === "headers")
610
+ return headers ??= wrapHeaders(target.headers, sourceSeq);
611
+ if (prop === "body") {
612
+ if (target.body === null)
613
+ return wrapChild(null, sourceSeq, ["body"]);
614
+ return body ??= wrapStream(target.body, sourceSeq, ["body"]);
615
+ }
616
+ if (prop === "clone")
617
+ return () => wrapResponse(target.clone(), sourceSeq);
469
618
  if (typeof prop === "string" && carrierProps.has(prop)) {
470
619
  const value = target[prop];
471
620
  return wrap(value, sourceSeq, [prop]);
@@ -476,7 +625,7 @@ export function wrapResponse(res, sourceSeq) {
476
625
  return fn;
477
626
  return async (...args) => {
478
627
  const value = await fn.apply(target, args);
479
- return wrap(value, sourceSeq, ["body"]);
628
+ return wrapChild(value, sourceSeq, ["body"]);
480
629
  };
481
630
  }
482
631
  const value = target[prop];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.88.3",
3
+ "version": "0.89.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",