@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
|
@@ -1,7 +1,55 @@
|
|
|
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
|
+
`;
|
|
37
|
+
|
|
1
38
|
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
39
|
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
40
|
export const ELECTRON_CHROME_HOOK = String.raw`
|
|
4
41
|
const electron = require('electron');
|
|
42
|
+
${ELECTRON_CA_TRUST}
|
|
43
|
+
const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
|
|
44
|
+
if (environmentCa) {
|
|
45
|
+
// Every session, including the default one and app-created partitions.
|
|
46
|
+
electron.app.on('session-created', session => {
|
|
47
|
+
session.setCertificateVerifyProc((request, callback) => {
|
|
48
|
+
if (request.errorCode === 0) return callback(0);
|
|
49
|
+
callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
5
53
|
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
54
|
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
55
|
construct(Target, args, NewTarget) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { electron } from './electron.js';
|
|
@@ -36,3 +36,86 @@ describe('Electron component contract', () => {
|
|
|
36
36
|
expect(isDesktopApp(null)).toBe(false);
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
describe('Electron component in a monorepo', () => {
|
|
41
|
+
function monorepo(run: (root: string) => void) {
|
|
42
|
+
const root = mkdtempSync(join(tmpdir(), 'spectest-electron-mono-'));
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(join(root, 'apps', 'desktop'), { recursive: true });
|
|
45
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['apps/*'], devDependencies: { electron: '44.0.0' } }));
|
|
46
|
+
writeFileSync(join(root, 'package-lock.json'), '{}');
|
|
47
|
+
writeFileSync(join(root, 'apps', 'desktop', 'package.json'), JSON.stringify({ name: '@acme/desktop' }));
|
|
48
|
+
run(root);
|
|
49
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
50
|
+
}
|
|
51
|
+
test('builds from the context root and runs the app from its workspace', () => monorepo(root => {
|
|
52
|
+
const service = electron({ appDir: join(root, 'apps/desktop'), context: root });
|
|
53
|
+
expect(service.image).toMatchObject({ context: root });
|
|
54
|
+
const content = (service.image as { content: string }).content;
|
|
55
|
+
expect(content).toContain('COPY . .\nRUN npm ci\n');
|
|
56
|
+
expect(content).toContain("RUN cd apps/desktop && node \"$(node -p \"require.resolve('electron/install.js')\")\"\n");
|
|
57
|
+
expect(content).toContain('RUN cd apps/desktop && npm run build\n');
|
|
58
|
+
expect(service.env?.SPECTEST_ELECTRON_APP_DIR).toBe('/app/apps/desktop');
|
|
59
|
+
}));
|
|
60
|
+
test('accepts electron declared at the context root only', () => monorepo(root => {
|
|
61
|
+
expect(() => electron({ appDir: join(root, 'apps/desktop'), context: root })).not.toThrow();
|
|
62
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['apps/*'] }));
|
|
63
|
+
expect(() => electron({ appDir: join(root, 'apps/desktop'), context: root })).toThrow('declare electron');
|
|
64
|
+
}));
|
|
65
|
+
test('rejects an app directory outside the context', () => monorepo(root => {
|
|
66
|
+
expect(() => electron({ appDir: root, context: join(root, 'apps/desktop') })).toThrow('must be inside context');
|
|
67
|
+
}));
|
|
68
|
+
test('the default context keeps the single-app image shape', () => fixture(appDir => {
|
|
69
|
+
const content = (electron({ appDir }).image as { content: string }).content;
|
|
70
|
+
expect(content).toContain('RUN node "$(node -p "require.resolve(\'electron/install.js\')")"\nRUN npm run build\n');
|
|
71
|
+
expect(electron({ appDir }).env?.SPECTEST_ELECTRON_APP_DIR).toBe('/app');
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('Electron environment-CA trust', () => {
|
|
76
|
+
const { ELECTRON_CA_TRUST } = require('./electron-chrome.js');
|
|
77
|
+
const { execFileSync } = require('node:child_process');
|
|
78
|
+
const { readFileSync: read } = require('node:fs');
|
|
79
|
+
function pki(run: (dir: string) => void) {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), 'spectest-electron-ca-'));
|
|
81
|
+
const sh = (cmd: string) => execFileSync('sh', ['-c', cmd], { cwd: dir, stdio: 'pipe' });
|
|
82
|
+
try {
|
|
83
|
+
sh('openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 2 -subj /CN=env-ca 2>/dev/null');
|
|
84
|
+
sh('openssl req -x509 -newkey rsa:2048 -nodes -keyout other.key -out other.pem -days 2 -subj /CN=other-ca 2>/dev/null');
|
|
85
|
+
sh('printf "subjectAltName=DNS:api.example.test\\nbasicConstraints=CA:FALSE\\n" > ext.cnf');
|
|
86
|
+
sh('openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj /CN=api.example.test 2>/dev/null');
|
|
87
|
+
sh('openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out leaf.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
88
|
+
sh('openssl x509 -req -in leaf.csr -CA other.pem -CAkey other.key -CAcreateserial -out stranger.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
89
|
+
sh('printf "basicConstraints=CA:TRUE\\nkeyUsage=keyCertSign\\n" > ica.cnf');
|
|
90
|
+
sh('openssl req -newkey rsa:2048 -nodes -keyout ica.key -out ica.csr -subj /CN=env-intermediate 2>/dev/null');
|
|
91
|
+
sh('openssl x509 -req -in ica.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out ica.pem -days 2 -extfile ica.cnf 2>/dev/null');
|
|
92
|
+
sh('openssl x509 -req -in leaf.csr -CA ica.pem -CAkey ica.key -CAcreateserial -out leaf2.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
93
|
+
run(dir);
|
|
94
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
95
|
+
}
|
|
96
|
+
// The hook's source, evaluated the way Electron evaluates it: plain CommonJS.
|
|
97
|
+
const trust = new Function('require', `${ELECTRON_CA_TRUST}; return { loadEnvironmentCa, issuedByEnvironmentCa };`)(require);
|
|
98
|
+
const request = (leafPath: string, hostname: string, issuers: string[] = []) => {
|
|
99
|
+
let cert: any = undefined;
|
|
100
|
+
for (const p of [...issuers].reverse()) cert = { data: read(p, 'utf8'), issuerCert: cert };
|
|
101
|
+
return { hostname, errorCode: -202, certificate: { data: read(leafPath, 'utf8'), issuerCert: cert } };
|
|
102
|
+
};
|
|
103
|
+
test('accepts a leaf the environment CA issued for the requested host', () => pki(dir => {
|
|
104
|
+
const ca = trust.loadEnvironmentCa(join(dir, 'ca.pem'));
|
|
105
|
+
expect(ca).not.toBeNull();
|
|
106
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf.pem'), 'api.example.test'))).toBe(true);
|
|
107
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test', [join(dir, 'ica.pem')]))).toBe(true);
|
|
108
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test', [join(dir, 'ica.pem'), join(dir, 'ca.pem')]))).toBe(true);
|
|
109
|
+
}));
|
|
110
|
+
test('rejects other issuers, other hosts, a bare intermediate, and the CA itself', () => pki(dir => {
|
|
111
|
+
const ca = trust.loadEnvironmentCa(join(dir, 'ca.pem'));
|
|
112
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'stranger.pem'), 'api.example.test'))).toBe(false);
|
|
113
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf.pem'), 'evil.example.test'))).toBe(false);
|
|
114
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test'))).toBe(false);
|
|
115
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'ca.pem'), 'api.example.test'))).toBe(false);
|
|
116
|
+
}));
|
|
117
|
+
test('no CA file means no trust hook', () => {
|
|
118
|
+
expect(trust.loadEnvironmentCa(undefined)).toBeNull();
|
|
119
|
+
expect(trust.loadEnvironmentCa('/nonexistent/ca.pem')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import type { ServiceDefinition } from "../index.js";
|
|
3
4
|
import { resolveProjectPath } from "../project-files.js";
|
|
4
5
|
import { desktopApp, type DesktopApp } from "../desktop.js";
|
|
5
6
|
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
6
7
|
|
|
7
8
|
export interface ElectronOptions {
|
|
8
|
-
/** Project-relative app directory; must contain package.json
|
|
9
|
+
/** Project-relative app directory; must contain package.json. Electron must
|
|
10
|
+
* be declared here or in the context's package.json. */
|
|
9
11
|
appDir?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Image build context, relative to the project root. Default `appDir`.
|
|
14
|
+
* Set it to the repository root when the app is one workspace of a
|
|
15
|
+
* monorepo and resolves packages from above its own directory. The
|
|
16
|
+
* context is copied to `/app`; `installCommand` runs at the context root,
|
|
17
|
+
* `buildCommand` and Electron run in the app directory below it.
|
|
18
|
+
*/
|
|
19
|
+
context?: string;
|
|
10
20
|
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
11
21
|
nodeVersion?: string;
|
|
12
22
|
/** Build the main/preload/renderer output; default npm run build. */
|
|
@@ -26,13 +36,14 @@ export const ELECTRON_LAUNCHER = String.raw`
|
|
|
26
36
|
import http from 'node:http';
|
|
27
37
|
import { spawn } from 'node:child_process';
|
|
28
38
|
import { createRequire } from 'node:module';
|
|
29
|
-
const
|
|
39
|
+
const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
|
|
40
|
+
const require = createRequire(appDir + '/package.json');
|
|
30
41
|
const child = spawn(require('electron'), [
|
|
31
42
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
32
43
|
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
33
44
|
'-r', '/spectest-electron-chrome.cjs',
|
|
34
45
|
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
35
|
-
], { cwd:
|
|
46
|
+
], { cwd: appDir, stdio: 'inherit', env: process.env });
|
|
36
47
|
const upstream = req => ({
|
|
37
48
|
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
38
49
|
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
@@ -68,18 +79,42 @@ child.on('exit', code => process.exit(code ?? 1));
|
|
|
68
79
|
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
69
80
|
`;
|
|
70
81
|
|
|
82
|
+
/** Read a package.json and tell whether it declares electron. */
|
|
83
|
+
function declaresElectron(manifest: string): boolean {
|
|
84
|
+
if (!existsSync(manifest)) return false;
|
|
85
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
86
|
+
return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The app directory as a path inside the build context: "." or "a/b". */
|
|
90
|
+
function appPathInContext(appDir: string, context: string): string {
|
|
91
|
+
const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
|
|
92
|
+
if (rel === "") return ".";
|
|
93
|
+
if (rel.startsWith("..") || posix.isAbsolute(rel)) {
|
|
94
|
+
throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
|
|
95
|
+
}
|
|
96
|
+
return rel;
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
72
100
|
* Like expo(), returns an app handle for the matching context method.
|
|
73
101
|
* Requires Linux-compatible application dependencies and binaries. */
|
|
74
102
|
export function electron(options: ElectronOptions = {}) {
|
|
75
103
|
const appDir = options.appDir ?? ".";
|
|
104
|
+
const context = options.context ?? appDir;
|
|
105
|
+
const appPath = appPathInContext(appDir, context);
|
|
76
106
|
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
77
107
|
if (!existsSync(manifest)) throw new Error(`electron(): no package.json in ${appDir}`);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
// A monorepo declares electron in the workspace or at the root; both work
|
|
109
|
+
// because the app resolves `electron` upward from its own directory.
|
|
110
|
+
if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"electron(): declare electron in the app's dependencies or devDependencies" +
|
|
113
|
+
(context === appDir ? "" : ` (in ${appDir} or in the context ${context})`),
|
|
114
|
+
);
|
|
81
115
|
}
|
|
82
|
-
|
|
116
|
+
// The install runs at the context root, so that is where the lockfile counts.
|
|
117
|
+
const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
|
|
83
118
|
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
84
119
|
if (!/^[\w.-]+$/.test(node)) throw new Error("electron(): invalid Node image tag");
|
|
85
120
|
const install = options.installCommand ?? (locked ? "npm ci" : "npm install");
|
|
@@ -87,18 +122,22 @@ export function electron(options: ElectronOptions = {}) {
|
|
|
87
122
|
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
88
123
|
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
89
124
|
}
|
|
125
|
+
// Electron can be hoisted to the context root, so find its install script
|
|
126
|
+
// through Node's resolution from the app directory instead of a fixed path.
|
|
127
|
+
const inApp = appPath === "." ? "" : `cd ${appPath} && `;
|
|
128
|
+
const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
|
|
90
129
|
return {
|
|
91
130
|
image: {
|
|
92
131
|
type: "dockerfile",
|
|
93
|
-
context
|
|
132
|
+
context,
|
|
94
133
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
95
134
|
content: `FROM node:${node}
|
|
96
135
|
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/*
|
|
97
136
|
WORKDIR /app
|
|
98
137
|
COPY . .
|
|
99
138
|
RUN ${install}
|
|
100
|
-
RUN
|
|
101
|
-
RUN ${build}
|
|
139
|
+
RUN ${fetchElectron}
|
|
140
|
+
RUN ${inApp}${build}
|
|
102
141
|
`,
|
|
103
142
|
},
|
|
104
143
|
files: [
|
|
@@ -106,7 +145,11 @@ RUN ${build}
|
|
|
106
145
|
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
107
146
|
],
|
|
108
147
|
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
109
|
-
env: {
|
|
148
|
+
env: {
|
|
149
|
+
...options.env,
|
|
150
|
+
SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
|
|
151
|
+
SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
|
|
152
|
+
},
|
|
110
153
|
dependsOn: options.dependsOn,
|
|
111
154
|
ports: [9223],
|
|
112
155
|
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { expect as assertValue } from "../index.js";
|
|
3
|
-
import { type WrappedResponse } from "../inspect.js";
|
|
4
|
-
import { recordFake, startRecording, stopRecording } from "../recorder.js";
|
|
3
|
+
import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
|
|
4
|
+
import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
|
|
5
5
|
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
6
|
import {
|
|
7
7
|
bindInstrumentationScope,
|
|
@@ -65,6 +65,72 @@ describe("fetch execution scopes", () => {
|
|
|
65
65
|
expect(stopRecording()).toEqual([]);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("header assertions retain HTTP provenance, including missing headers and cookies", async () => {
|
|
69
|
+
handler = () => new Response("ok", { headers: [
|
|
70
|
+
["content-type", "text/plain"],
|
|
71
|
+
["set-cookie", "a=1; Path=/"],
|
|
72
|
+
["set-cookie", "b=2; Path=/"],
|
|
73
|
+
] });
|
|
74
|
+
await runInstrumented(scope, async () => {
|
|
75
|
+
const res = await fetch(url("/headers")) as unknown as WrappedResponse;
|
|
76
|
+
assertValue(res.headers.get("Content-Type")).toBe("text/plain");
|
|
77
|
+
assertValue(res.headers.get("x-missing")).toBe(null);
|
|
78
|
+
assertValue(res.headers.has("CONTENT-TYPE")).toBe(true);
|
|
79
|
+
assertValue(res.headers.has("x-missing")).toBe(false);
|
|
80
|
+
assertValue(res.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]);
|
|
81
|
+
assertValue(res.headers.getSetCookie()[1]).toBe("b=2; Path=/");
|
|
82
|
+
});
|
|
83
|
+
const events = stopRecording();
|
|
84
|
+
expect(events.map((e) => e.kind)).toEqual(["http", ...Array(6).fill("assertion")]);
|
|
85
|
+
expect(events.slice(1)).toMatchObject([
|
|
86
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "content-type"] },
|
|
87
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "x-missing"] },
|
|
88
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "content-type", "<has>"] },
|
|
89
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "x-missing", "<has>"] },
|
|
90
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "set-cookie"] },
|
|
91
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "set-cookie", "1"] },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wrapped headers keep native iteration and expose raw headers for ordinary APIs", async () => {
|
|
96
|
+
await runInstrumented(scope, async () => {
|
|
97
|
+
const res = await fetch(url("/headers")) as unknown as WrappedResponse;
|
|
98
|
+
const headers = res.headers;
|
|
99
|
+
const raw = res.unwrap().headers;
|
|
100
|
+
expect(res.headers).toBe(headers);
|
|
101
|
+
expect(headers.unwrap()).toBe(raw);
|
|
102
|
+
expect<unknown>(readRaw(headers)).toBe(raw);
|
|
103
|
+
expect(headers.get("content-type")?.unwrap() ?? null).toBe(raw.get("content-type"));
|
|
104
|
+
expect(headers.get("missing")).toBe(null);
|
|
105
|
+
expect(headers.has("missing").unwrap()).toBe(false);
|
|
106
|
+
expect([...headers]).toEqual([...raw]);
|
|
107
|
+
expect([...headers.entries()]).toEqual([...raw.entries()]);
|
|
108
|
+
expect([...headers.keys()]).toEqual([...raw.keys()]);
|
|
109
|
+
expect([...headers.values()]).toEqual([...raw.values()]);
|
|
110
|
+
const entries: [string, string][] = [];
|
|
111
|
+
headers.forEach((value, key) => entries.push([key, value]));
|
|
112
|
+
expect(entries).toEqual([...raw]);
|
|
113
|
+
assertValue(headers.transform("entries", (h) => Object.fromEntries(h))["content-type"])
|
|
114
|
+
.toBe(raw.get("content-type"));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("cloned responses keep header provenance and independent bodies", async () => {
|
|
119
|
+
await runInstrumented(scope, async () => {
|
|
120
|
+
const res = await fetch(url("/clone")) as unknown as WrappedResponse;
|
|
121
|
+
const clone = res.clone();
|
|
122
|
+
assertValue(clone.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
123
|
+
expect(clone.unwrap()).not.toBe(res.unwrap());
|
|
124
|
+
expect((await clone.text()).unwrap()).toBe('{"value":42}');
|
|
125
|
+
expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
|
|
126
|
+
});
|
|
127
|
+
const events = stopRecording();
|
|
128
|
+
expect(events).toMatchObject([
|
|
129
|
+
{ kind: "http" },
|
|
130
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["headers", "content-type"] },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
68
134
|
test("an HTTP fake records only the test's outer request", async () => {
|
|
69
135
|
handler = (req) => {
|
|
70
136
|
if (new URL(req.url).pathname === "/fake") {
|
|
@@ -142,6 +208,42 @@ describe("fetch execution scopes", () => {
|
|
|
142
208
|
expect(stopRecording()).toEqual([]);
|
|
143
209
|
}, 2_000);
|
|
144
210
|
|
|
211
|
+
test.each([
|
|
212
|
+
{ contentType: "text/event-stream; charset=utf-8", captureBody: undefined },
|
|
213
|
+
{ contentType: "text/plain", captureBody: false },
|
|
214
|
+
])("instrumented streams return before closing: %j", async ({ contentType, captureBody }) => {
|
|
215
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
216
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
217
|
+
start(c) {
|
|
218
|
+
controller = c;
|
|
219
|
+
c.enqueue(new TextEncoder().encode("first chunk"));
|
|
220
|
+
},
|
|
221
|
+
}), { headers: { "content-type": contentType } });
|
|
222
|
+
try {
|
|
223
|
+
await runInstrumented(scope, async () => {
|
|
224
|
+
const res = await (fetch as unknown as SpectestFetch)(url("/stream"), {
|
|
225
|
+
captureBody, signal: AbortSignal.timeout(1_000),
|
|
226
|
+
});
|
|
227
|
+
assertValue(res.bodyUsed).toBe(false);
|
|
228
|
+
const reader = res.body!.getReader();
|
|
229
|
+
try {
|
|
230
|
+
const chunk = await reader.read();
|
|
231
|
+
expect(chunk.done).toBe(false);
|
|
232
|
+
assertValue(chunk.value!.transform("text", (bytes) => new TextDecoder().decode(bytes)))
|
|
233
|
+
.toBe("first chunk");
|
|
234
|
+
} finally { reader.releaseLock(); }
|
|
235
|
+
});
|
|
236
|
+
} finally { controller?.close(); }
|
|
237
|
+
const events = stopRecording();
|
|
238
|
+
expect(events).toMatchObject([
|
|
239
|
+
{ kind: "http", status: 200 },
|
|
240
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["bodyUsed"] },
|
|
241
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["body", "<read>", "0", "<text>"] },
|
|
242
|
+
]);
|
|
243
|
+
if (events[0]?.kind !== "http") throw new Error("Missing HTTP event");
|
|
244
|
+
expect((events[0] as HttpEvent).responseBody).toBeUndefined();
|
|
245
|
+
}, 2_000);
|
|
246
|
+
|
|
145
247
|
test("test-authored ingress callbacks retain their scope but their fake upstream does not", async () => {
|
|
146
248
|
const callback = runInstrumented(scope, () => bindInstrumentationScope(async () => {
|
|
147
249
|
const res = await fetch(url("/interceptor")) as unknown as WrappedResponse;
|
|
@@ -241,6 +343,10 @@ describe("fetch execution scopes", () => {
|
|
|
241
343
|
await runInstrumented(setup, async () => {
|
|
242
344
|
const res = await fetch(url("/setup")) as unknown as WrappedResponse;
|
|
243
345
|
expect(res.status.unwrap()).toBe(200);
|
|
346
|
+
expect(res.headers.get("content-type")?.unwrap()).toBe("application/json;charset=utf-8");
|
|
347
|
+
expect(readTag(res.headers.get("content-type"))).toEqual({
|
|
348
|
+
sourceSeq: undefined, path: ["headers", "content-type"],
|
|
349
|
+
});
|
|
244
350
|
await runUninstrumented(async () => {
|
|
245
351
|
expect((await fetch(url("/setup-fake"))).status).toBe(200);
|
|
246
352
|
});
|
package/src/harness/fetch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { wrapResponse, type WrappedResponse } from "../inspect.js";
|
|
1
|
+
import { wrapResponse, type SpectestRequestInit, type WrappedResponse } from "../inspect.js";
|
|
2
2
|
import { recordHttp, reserveEvent, truncateUtf8, type OmittedBody } from "../recorder.js";
|
|
3
3
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength } from "./http-body.js";
|
|
4
4
|
import { currentInstrumentationScope } from "./instrumentation-scope.js";
|
|
@@ -72,12 +72,11 @@ export function isTransportError(err: unknown): boolean {
|
|
|
72
72
|
export function installFetchWrapper(): () => void {
|
|
73
73
|
const original = globalThis.fetch;
|
|
74
74
|
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
75
|
-
// wrapper:
|
|
76
|
-
// body to the end, which never finishes for an SSE stream.
|
|
75
|
+
// wrapper: its response properties and body readers return wrapped values.
|
|
77
76
|
setRawFetch(original);
|
|
78
77
|
const wrappedFn = async (
|
|
79
78
|
input: Parameters<typeof fetch>[0],
|
|
80
|
-
init?:
|
|
79
|
+
init?: SpectestRequestInit,
|
|
81
80
|
): Promise<Response | WrappedResponse> => {
|
|
82
81
|
const scope = currentInstrumentationScope();
|
|
83
82
|
if (!scope?.active) return original(input, init);
|
|
@@ -97,7 +96,11 @@ export function installFetchWrapper(): () => void {
|
|
|
97
96
|
const contentType = res.headers.get("content-type");
|
|
98
97
|
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
99
98
|
const textual = isTextualContentType(contentType);
|
|
100
|
-
|
|
99
|
+
const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
100
|
+
if (init?.captureBody === false || streaming) {
|
|
101
|
+
// Return on headers: an open-ended stream must not be drained (or
|
|
102
|
+
// cloned into an unbounded background buffer) by the recorder.
|
|
103
|
+
} else if (textual === false) {
|
|
101
104
|
responseBody = omittedBody("binary", contentType, contentLength());
|
|
102
105
|
} else {
|
|
103
106
|
try {
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,21 @@ export type {
|
|
|
17
17
|
Wrapped,
|
|
18
18
|
WrappedObject,
|
|
19
19
|
WrappedArray,
|
|
20
|
+
WrappedHeaders,
|
|
21
|
+
WrappedBuffer,
|
|
22
|
+
WrappedBinaryView,
|
|
23
|
+
WrappedBytes,
|
|
24
|
+
WrappedBlob,
|
|
25
|
+
WrappedFile,
|
|
26
|
+
WrappedFormData,
|
|
27
|
+
WrappedStream,
|
|
28
|
+
WrappedReader,
|
|
29
|
+
WrappedBYOBReader,
|
|
30
|
+
WrappedReadResult,
|
|
20
31
|
WrappedResponse,
|
|
21
32
|
Provenanced,
|
|
22
33
|
SpectestFetch,
|
|
34
|
+
SpectestRequestInit,
|
|
23
35
|
Unwrap,
|
|
24
36
|
} from "./inspect.js";
|
|
25
37
|
import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
@@ -475,11 +487,15 @@ export interface SpectestContext<
|
|
|
475
487
|
/**
|
|
476
488
|
* Instrumented `fetch`. In a test each call is recorded on the timeline
|
|
477
489
|
* and resolves to a {@link WrappedResponse}: reads carry provenance so
|
|
478
|
-
* `expect(res.status)
|
|
490
|
+
* `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
|
|
491
|
+
* `expect(await res.json())` nest under the HTTP
|
|
479
492
|
* call. Because the status-line accessors are {@link Carrier}s, a raw
|
|
480
493
|
* `res.status === 200` is a *type error* — use `res.status.unwrap()` /
|
|
481
494
|
* `res.unwrap().status`, or assert via `expect`. Outside a test the
|
|
482
495
|
* result is wrapped the same way, just with no timeline to link to.
|
|
496
|
+
* Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
|
|
497
|
+
* and stream reader values also retain provenance. Use `captureBody: false`
|
|
498
|
+
* for open-ended streams; SSE responses skip recorder body capture automatically.
|
|
483
499
|
*
|
|
484
500
|
* (The plain global `fetch` is wrapped the same way at runtime but keeps
|
|
485
501
|
* the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
|