@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.
@@ -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 and Electron. */
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 require = createRequire('/app/package.json');
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: '/app', stdio: 'inherit', env: process.env });
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
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
79
- if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
80
- throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
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
- const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
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: appDir,
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 node node_modules/electron/install.js
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: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
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 exports a `websocket`
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
- websocket: {
2489
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2490
- async open(ws: any) {
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 get routed through `server.upgrade()`, with
2661
- * the upstream URL stashed on `ws.data`. The shared `websocket`
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
- const wsData: WsBridgeData = {
2692
- upstreamUrl,
2693
- upstream: null,
2694
- pending: [],
2695
- };
2696
- const ok = server.upgrade(req, { data: wsData });
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
  });
@@ -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: a wrapped `res.ok` is an object, and the wrapper reads every
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?: Parameters<typeof fetch>[1],
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
- if (textual === false) {
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 {