@specific.dev/spectest 0.88.3 → 0.89.1
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/daemon.js +40 -85
- package/dist/harness/fetch.js +7 -3
- package/dist/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- 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/daemon.ts +48 -86
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
|
@@ -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 },
|
package/src/daemon.ts
CHANGED
|
@@ -124,6 +124,13 @@ import {
|
|
|
124
124
|
corsPreflightResponse,
|
|
125
125
|
isCorsPreflight,
|
|
126
126
|
} from "./harness/http-proxy.js";
|
|
127
|
+
import {
|
|
128
|
+
WS_BRIDGE_HANDLERS,
|
|
129
|
+
openUpstreamWebSocket,
|
|
130
|
+
parseSubprotocols,
|
|
131
|
+
upstreamHandshakeHeaders,
|
|
132
|
+
type WsBridgeData,
|
|
133
|
+
} from "./harness/ws-bridge.js";
|
|
127
134
|
import {
|
|
128
135
|
certCovers as hostmatchCertCovers,
|
|
129
136
|
hostWithoutPort,
|
|
@@ -2112,14 +2119,6 @@ async function bridgeGatewayIp(): Promise<string> {
|
|
|
2112
2119
|
* The shape is `harness/ingress-table.ts`'s, generic over that record. */
|
|
2113
2120
|
type Route = IngressRoute<FakeRuntime>;
|
|
2114
2121
|
|
|
2115
|
-
/** Per-WS-upgrade context: the upstream URL we'll open in `open()` and
|
|
2116
|
-
* keep the bridge in `message()`/`close()`. Stored on `ws.data`. */
|
|
2117
|
-
interface WsBridgeData {
|
|
2118
|
-
upstreamUrl: string;
|
|
2119
|
-
upstream: WebSocket | null;
|
|
2120
|
-
pending: Array<string | ArrayBuffer | Uint8Array>;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
2122
|
/**
|
|
2124
2123
|
* Pristine `fetch` captured at module load, before any test-scoped
|
|
2125
2124
|
* fetch wrapper can monkey-patch `globalThis.fetch`. The reverse-proxy
|
|
@@ -2440,8 +2439,8 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
|
|
|
2440
2439
|
* Spin up one Bun.serve listener bound to (port, optional TLS) that
|
|
2441
2440
|
* dispatches every request to the matching Route by Host header.
|
|
2442
2441
|
*
|
|
2443
|
-
* Shared by the HTTP and HTTPS branches. Also
|
|
2444
|
-
* handler so reverse-proxy targets can transparently bridge WS
|
|
2442
|
+
* Shared by the HTTP and HTTPS branches. Also carries the `websocket`
|
|
2443
|
+
* handler set so reverse-proxy targets can transparently bridge WS
|
|
2445
2444
|
* upgrades through to their upstream service.
|
|
2446
2445
|
*/
|
|
2447
2446
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2485,74 +2484,9 @@ function bindIngressServer(
|
|
|
2485
2484
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2486
2485
|
fetch: (req: Request, server: any): Response | Promise<Response> =>
|
|
2487
2486
|
dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
const data = ws.data as WsBridgeData;
|
|
2492
|
-
try {
|
|
2493
|
-
const upstream = new WebSocket(data.upstreamUrl);
|
|
2494
|
-
// ArrayBuffer so binary frames can be ws.send()'d to the
|
|
2495
|
-
// downstream client verbatim — Blob would need an extra
|
|
2496
|
-
// .arrayBuffer() round-trip on every message.
|
|
2497
|
-
upstream.binaryType = "arraybuffer";
|
|
2498
|
-
data.upstream = upstream;
|
|
2499
|
-
upstream.addEventListener("open", () => {
|
|
2500
|
-
for (const m of data.pending) upstream.send(m);
|
|
2501
|
-
data.pending = [];
|
|
2502
|
-
});
|
|
2503
|
-
upstream.addEventListener("message", (ev: MessageEvent) => {
|
|
2504
|
-
try {
|
|
2505
|
-
ws.send(ev.data);
|
|
2506
|
-
} catch {
|
|
2507
|
-
/* client gone */
|
|
2508
|
-
}
|
|
2509
|
-
});
|
|
2510
|
-
upstream.addEventListener("close", (ev: CloseEvent) => {
|
|
2511
|
-
try {
|
|
2512
|
-
ws.close(ev.code, ev.reason);
|
|
2513
|
-
} catch {
|
|
2514
|
-
/* already closed */
|
|
2515
|
-
}
|
|
2516
|
-
});
|
|
2517
|
-
upstream.addEventListener("error", () => {
|
|
2518
|
-
try {
|
|
2519
|
-
ws.close(1011, "upstream error");
|
|
2520
|
-
} catch {
|
|
2521
|
-
/* already closed */
|
|
2522
|
-
}
|
|
2523
|
-
});
|
|
2524
|
-
} catch (err) {
|
|
2525
|
-
// eslint-disable-next-line no-console
|
|
2526
|
-
console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
|
|
2527
|
-
try {
|
|
2528
|
-
ws.close(1011, "upstream open failed");
|
|
2529
|
-
} catch {
|
|
2530
|
-
/* ignore */
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
|
-
},
|
|
2534
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2535
|
-
message(ws: any, message: string | Buffer) {
|
|
2536
|
-
const data = ws.data as WsBridgeData;
|
|
2537
|
-
const payload =
|
|
2538
|
-
typeof message === "string" ? message : new Uint8Array(message);
|
|
2539
|
-
if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
|
|
2540
|
-
data.upstream.send(payload);
|
|
2541
|
-
} else {
|
|
2542
|
-
// Buffer until the upstream finishes its handshake.
|
|
2543
|
-
data.pending.push(payload);
|
|
2544
|
-
}
|
|
2545
|
-
},
|
|
2546
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2547
|
-
close(ws: any, code: number, reason: string) {
|
|
2548
|
-
const data = ws.data as WsBridgeData;
|
|
2549
|
-
try {
|
|
2550
|
-
data.upstream?.close(code, reason);
|
|
2551
|
-
} catch {
|
|
2552
|
-
/* ignore */
|
|
2553
|
-
}
|
|
2554
|
-
},
|
|
2555
|
-
},
|
|
2487
|
+
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
2488
|
+
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
2489
|
+
websocket: WS_BRIDGE_HANDLERS,
|
|
2556
2490
|
};
|
|
2557
2491
|
return Bun.serve(opts);
|
|
2558
2492
|
}
|
|
@@ -2657,8 +2591,12 @@ function ingressClientIp(server: any, req: Request, proto: string): string | und
|
|
|
2657
2591
|
* Reverse-proxy a request to `http://<service>:<port>` on
|
|
2658
2592
|
* `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
|
|
2659
2593
|
*
|
|
2660
|
-
* - WS upgrade requests
|
|
2661
|
-
*
|
|
2594
|
+
* - WS upgrade requests open the upstream WebSocket first, with the
|
|
2595
|
+
* client's full `Sec-WebSocket-Protocol` list and its forwardable
|
|
2596
|
+
* headers (cookies, authorization, origin, x-forwarded-*). Only after
|
|
2597
|
+
* that handshake succeeds is the client upgraded, with the protocol
|
|
2598
|
+
* the upstream selected; a refused upstream handshake is a 502, not a
|
|
2599
|
+
* 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
|
|
2662
2600
|
* handler opens the upstream and bridges frames both ways.
|
|
2663
2601
|
* - Plain requests pass through via `fetch()` with hop-by-hop
|
|
2664
2602
|
* headers stripped; the response body is a ReadableStream returned
|
|
@@ -2688,16 +2626,40 @@ async function proxyToService(
|
|
|
2688
2626
|
const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
|
|
2689
2627
|
if (upgrade === "websocket") {
|
|
2690
2628
|
const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2629
|
+
// The upstream handshake carries what the client's did: the whole
|
|
2630
|
+
// subprotocol list (a console ticket often rides as the second entry)
|
|
2631
|
+
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2632
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2633
|
+
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2634
|
+
service,
|
|
2635
|
+
port,
|
|
2636
|
+
proto,
|
|
2637
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2638
|
+
});
|
|
2639
|
+
const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
|
|
2640
|
+
if (!opened.ok) {
|
|
2641
|
+
return new Response(
|
|
2642
|
+
`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`,
|
|
2643
|
+
{ status: 502, headers: { "content-type": "text/plain" } },
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
const wsData: WsBridgeData = { upstreamUrl, upstream: opened.socket };
|
|
2647
|
+
// Echo the protocol the upstream selected; Bun would otherwise pick
|
|
2648
|
+
// the client's first entry by itself, which can differ.
|
|
2649
|
+
const selected = opened.socket.protocol;
|
|
2650
|
+
const ok = server.upgrade(req, {
|
|
2651
|
+
data: wsData,
|
|
2652
|
+
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2653
|
+
});
|
|
2697
2654
|
if (ok) {
|
|
2698
2655
|
// Bun has already taken over the response — return a stub.
|
|
2699
2656
|
return new Response(null, { status: 101 });
|
|
2700
2657
|
}
|
|
2658
|
+
try {
|
|
2659
|
+
opened.socket.close(1011, "client upgrade refused");
|
|
2660
|
+
} catch {
|
|
2661
|
+
/* ignore */
|
|
2662
|
+
}
|
|
2701
2663
|
return new Response(
|
|
2702
2664
|
`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`,
|
|
2703
2665
|
{ status: 426, headers: { "content-type": "text/plain" } },
|
|
@@ -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 {
|