@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 +42 -2
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/harness/fetch.js +7 -3
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
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
|
|
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,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
|
|
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
|
|
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:
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
|
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 },
|
package/dist/harness/fetch.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
226
|
-
*
|
|
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:
|
|
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<
|
|
240
|
-
|
|
241
|
-
|
|
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?:
|
|
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`.
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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`.
|
|
444
|
-
*
|
|
445
|
-
*
|
|
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
|
|
628
|
+
return wrapChild(value, sourceSeq, ["body"]);
|
|
480
629
|
};
|
|
481
630
|
}
|
|
482
631
|
const value = target[prop];
|