@specific.dev/spectest 0.88.2 → 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 ADDED
@@ -0,0 +1,108 @@
1
+ # Spectest SDK
2
+
3
+ Spectest runs your application and its dependencies in isolated environments.
4
+ Tests can fork a parent's live state, including memory and files, then drive
5
+ user flows with recorded assertions and graphical replays.
6
+
7
+ Install the SDK in your project's `spectest/` directory:
8
+
9
+ ```sh
10
+ mkdir -p spectest
11
+ npm install --prefix spectest --save-exact @specific.dev/spectest@0.88.3
12
+ ```
13
+
14
+ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/latest)
15
+ and run `spectest docs /installation` for project setup and authentication.
16
+ `spectest docs` contains the complete offline authoring guide.
17
+
18
+ ## HTTP assertions
19
+
20
+ `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
21
+ all body readers retain the HTTP call's provenance, so assertions appear under
22
+ that call on the timeline:
23
+
24
+ ```ts
25
+ const response = await ctx.fetch("http://app:3000/image.png");
26
+ expect(response.status).toBe(200);
27
+ expect(response.headers.get("content-type")).toBe("image/png");
28
+ const bytes = await response.bytes();
29
+ expect(bytes).toHaveLength(70);
30
+ expect(bytes[0]).toBe(0x89);
31
+ expect(bytes.slice(0, 8)).toEqual(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10]));
32
+ ```
33
+
34
+ `arrayBuffer()` wraps buffer metadata and slices. `blob()` wraps metadata and
35
+ its body readers. `formData()` wraps `get()`, `getAll()`, and `has()`, including
36
+ uploaded files and their metadata. Headers also support wrapped `getSetCookie()`
37
+ and Bun's `getAll()`, `toJSON()`, and `count`. Missing fields remain `null`, with
38
+ provenance recovered by an immediately following `expect`.
39
+
40
+ Use `.unwrap()` to pass a wrapped value to a native API, such as
41
+ `new TextDecoder().decode(bytes.unwrap())`. Use `.transform("text", bytes =>
42
+ new TextDecoder().decode(bytes))` when the derived value should retain provenance.
43
+ Iteration and callback arguments remain raw, as do array/typed-array `length`
44
+ and stream control flags (`done`, `locked`). Use `expect(bytes).toHaveLength(n)`
45
+ for a linked length assertion. `bodyUsed` is wrapped: unwrap it for control flow.
46
+
47
+ For an open-ended stream, pass `{ captureBody: false }` to return without waiting
48
+ for recorder body capture. SSE (`text/event-stream`) skips capture automatically.
49
+ The HTTP event and assertions are still recorded; the event omits the body.
50
+ `response.body.getReader().read()` returns a raw `done` flag and wrapped `value`.
51
+ BYOB readers, `tee()`, and `pipeThrough()` preserve provenance too. Release reader
52
+ locks and cancel streams when finished, as with native fetch.
53
+
54
+ ## Electron apps
55
+
56
+ Use `electron()` to build and run a real Linux Electron app under Xvfb, with
57
+ its main process, preload, IPC and renderer. Spectest supplies the container
58
+ setup; no custom Dockerfile is needed.
59
+
60
+ Create `spectest/index.ts`:
61
+
62
+ ```ts
63
+ import { defineEnvironment } from "@specific.dev/spectest";
64
+ import { electron } from "@specific.dev/spectest/components";
65
+
66
+ export const env = defineEnvironment({
67
+ name: "desktop-app",
68
+ services: { app: electron() },
69
+ });
70
+
71
+ export default env.project();
72
+ ```
73
+
74
+ Create `spectest/tests/notes.ts`, using your app's labels:
75
+
76
+ ```ts
77
+ import { expect } from "@specific.dev/spectest";
78
+ import { env } from "../index";
79
+
80
+ env.test("Save a note", async (ctx) => {
81
+ const app = await ctx.desktop(ctx.svc.app);
82
+ await app.getByRole("textbox", { name: "New note" }).fill("Hello desktop");
83
+ await app.getByRole("button", { name: "Add note", exact: true }).click();
84
+ await expect(app.getByRole("listitem")).toHaveText(["Hello desktop"]);
85
+ });
86
+ ```
87
+
88
+ Run `spectest test .` from the project root. The dashboard records desktop
89
+ interactions and replays the app with window chrome derived from its Electron
90
+ configuration. Child tests using `dependsOn` inherit the live app state,
91
+ including unsaved input, in their own isolated environment.
92
+
93
+ The app must declare Electron in `package.json` (its own or, with `context`
94
+ set to a monorepo root, the root one). Defaults are `npm ci` with a
95
+ package lock (otherwise `npm install`), `npm run build`, and the package's
96
+ `main` entry. Use `appDir`, `installCommand`, `buildCommand`, `entry`, and
97
+ `nodeVersion` to customize the build. `env` supplies runtime variables;
98
+ `dependsOn` waits for backend services in the same environment. Expose the
99
+ backend through the built-in proxy (`tls: [{ hostname, port }]`) and give the
100
+ app its `https://` URL: a renderer Content Security Policy usually refuses a
101
+ plain `http://` service address.
102
+
103
+ Applications need Linux-compatible dependencies and binaries. Native OS dialogs
104
+ and macOS-specific APIs are outside this basic support. Replays capture the
105
+ renderer DOM from acquisition onward, not native OS surfaces.
106
+
107
+ Run `spectest docs /components/electron` for the full configuration reference,
108
+ backend setup, session behavior, and replay scope (CLI 0.2.124 or later).
@@ -1 +1,2 @@
1
+ export declare const ELECTRON_CA_TRUST: string;
1
2
  export declare const ELECTRON_CHROME_HOOK: string;
@@ -1,7 +1,54 @@
1
+ // Trust for the environment CA, applied per session. Chromium on Linux
2
+ // reads its own root store (NSS), never the container's mounted bundle, so
3
+ // the renderer rejects every certificate the environment mints while Node
4
+ // in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
5
+ // procedure accepts exactly the chains that CA issued for the requested
6
+ // host, and defers to Chromium's own verdict for everything else. An app
7
+ // that installs its own procedure later replaces it, as in production.
8
+ // Kept free of Electron so the unit test can run it under Bun.
9
+ export const ELECTRON_CA_TRUST = String.raw `
10
+ const { X509Certificate } = require('node:crypto');
11
+ function loadEnvironmentCa(path) {
12
+ try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
13
+ catch { return null; }
14
+ }
15
+ // request: { hostname, errorCode, certificate: { data, issuerCert? } }.
16
+ // Returns true only for a chain that ends at the CA and names the host.
17
+ function issuedByEnvironmentCa(ca, request) {
18
+ try {
19
+ const leaf = new X509Certificate(request.certificate.data);
20
+ const host = request.hostname;
21
+ const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
22
+ if (!named) return false;
23
+ const chain = [leaf];
24
+ for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
25
+ chain.push(new X509Certificate(c.issuerCert.data));
26
+ }
27
+ for (let i = 0; i < chain.length; i++) {
28
+ const cert = chain[i];
29
+ if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
30
+ const issuer = chain[i + 1] ?? ca;
31
+ if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
32
+ }
33
+ return true;
34
+ } catch { return false; }
35
+ }
36
+ `;
1
37
  // Runs before the application's main entry, like Playwright's Electron loader.
2
38
  // Observe constructor options; do not replace the app entry, preload, IPC, or OS.
3
39
  export const ELECTRON_CHROME_HOOK = String.raw `
4
40
  const electron = require('electron');
41
+ ${ELECTRON_CA_TRUST}
42
+ const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
43
+ if (environmentCa) {
44
+ // Every session, including the default one and app-created partitions.
45
+ electron.app.on('session-created', session => {
46
+ session.setCertificateVerifyProc((request, callback) => {
47
+ if (request.errorCode === 0) return callback(0);
48
+ callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
49
+ });
50
+ });
51
+ }
5
52
  const NativeBrowserWindow = electron.BrowserWindow;
6
53
  const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
7
54
  construct(Target, args, NewTarget) {
@@ -1,7 +1,16 @@
1
1
  import { type DesktopApp } from "../desktop.js";
2
2
  export interface ElectronOptions {
3
- /** Project-relative app directory; must contain package.json and Electron. */
3
+ /** Project-relative app directory; must contain package.json. Electron must
4
+ * be declared here or in the context's package.json. */
4
5
  appDir?: string;
6
+ /**
7
+ * Image build context, relative to the project root. Default `appDir`.
8
+ * Set it to the repository root when the app is one workspace of a
9
+ * monorepo and resolves packages from above its own directory. The
10
+ * context is copied to `/app`; `installCommand` runs at the context root,
11
+ * `buildCommand` and Electron run in the app directory below it.
12
+ */
13
+ context?: string;
5
14
  /** Debian Node image tag, default 24-bookworm-slim. */
6
15
  nodeVersion?: string;
7
16
  /** Build the main/preload/renderer output; default npm run build. */
@@ -31,6 +40,7 @@ export declare function electron(options?: ElectronOptions): {
31
40
  command: string;
32
41
  env: {
33
42
  SPECTEST_ELECTRON_ENTRY: string;
43
+ SPECTEST_ELECTRON_APP_DIR: string;
34
44
  };
35
45
  dependsOn: string[] | undefined;
36
46
  ports: number[];
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { posix } from "node:path";
2
3
  import { resolveProjectPath } from "../project-files.js";
3
4
  import { desktopApp } from "../desktop.js";
4
5
  import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
@@ -9,13 +10,14 @@ export const ELECTRON_LAUNCHER = String.raw `
9
10
  import http from 'node:http';
10
11
  import { spawn } from 'node:child_process';
11
12
  import { createRequire } from 'node:module';
12
- const require = createRequire('/app/package.json');
13
+ const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
14
+ const require = createRequire(appDir + '/package.json');
13
15
  const child = spawn(require('electron'), [
14
16
  ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
15
17
  '--disable-dev-shm-usage', '--remote-debugging-port=9222',
16
18
  '-r', '/spectest-electron-chrome.cjs',
17
19
  process.env.SPECTEST_ELECTRON_ENTRY || '.',
18
- ], { cwd: '/app', stdio: 'inherit', env: process.env });
20
+ ], { cwd: appDir, stdio: 'inherit', env: process.env });
19
21
  const upstream = req => ({
20
22
  host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
21
23
  headers: { ...req.headers, host: '127.0.0.1:9222' },
@@ -50,19 +52,41 @@ child.on('error', error => { console.error(error); process.exit(1); });
50
52
  child.on('exit', code => process.exit(code ?? 1));
51
53
  for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
52
54
  `;
55
+ /** Read a package.json and tell whether it declares electron. */
56
+ function declaresElectron(manifest) {
57
+ if (!existsSync(manifest))
58
+ return false;
59
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
60
+ return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
61
+ }
62
+ /** The app directory as a path inside the build context: "." or "a/b". */
63
+ function appPathInContext(appDir, context) {
64
+ const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
65
+ if (rel === "")
66
+ return ".";
67
+ if (rel.startsWith("..") || posix.isAbsolute(rel)) {
68
+ throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
69
+ }
70
+ return rel;
71
+ }
53
72
  /** Real Linux Electron under Xvfb, with a generated image and graphical replay.
54
73
  * Like expo(), returns an app handle for the matching context method.
55
74
  * Requires Linux-compatible application dependencies and binaries. */
56
75
  export function electron(options = {}) {
57
76
  const appDir = options.appDir ?? ".";
77
+ const context = options.context ?? appDir;
78
+ const appPath = appPathInContext(appDir, context);
58
79
  const manifest = resolveProjectPath(`${appDir}/package.json`);
59
80
  if (!existsSync(manifest))
60
81
  throw new Error(`electron(): no package.json in ${appDir}`);
61
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
62
- if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
63
- throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
82
+ // A monorepo declares electron in the workspace or at the root; both work
83
+ // because the app resolves `electron` upward from its own directory.
84
+ if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
85
+ throw new Error("electron(): declare electron in the app's dependencies or devDependencies" +
86
+ (context === appDir ? "" : ` (in ${appDir} or in the context ${context})`));
64
87
  }
65
- const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
88
+ // The install runs at the context root, so that is where the lockfile counts.
89
+ const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
66
90
  const node = options.nodeVersion ?? "24-bookworm-slim";
67
91
  if (!/^[\w.-]+$/.test(node))
68
92
  throw new Error("electron(): invalid Node image tag");
@@ -71,18 +95,22 @@ export function electron(options = {}) {
71
95
  if ([install, build].some(command => /[\r\n]/.test(command))) {
72
96
  throw new Error("electron(): build/install commands must be single-line shell commands");
73
97
  }
98
+ // Electron can be hoisted to the context root, so find its install script
99
+ // through Node's resolution from the app directory instead of a fixed path.
100
+ const inApp = appPath === "." ? "" : `cd ${appPath} && `;
101
+ const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
74
102
  return {
75
103
  image: {
76
104
  type: "dockerfile",
77
- context: appDir,
105
+ context,
78
106
  exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
79
107
  content: `FROM node:${node}
80
108
  RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
81
109
  WORKDIR /app
82
110
  COPY . .
83
111
  RUN ${install}
84
- RUN node node_modules/electron/install.js
85
- RUN ${build}
112
+ RUN ${fetchElectron}
113
+ RUN ${inApp}${build}
86
114
  `,
87
115
  },
88
116
  files: [
@@ -90,7 +118,11 @@ RUN ${build}
90
118
  { path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
91
119
  ],
92
120
  command: "xvfb-run -a node /spectest-electron.mjs",
93
- env: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
121
+ env: {
122
+ ...options.env,
123
+ SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
124
+ SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
125
+ },
94
126
  dependsOn: options.dependsOn,
95
127
  ports: [9223],
96
128
  readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
@@ -0,0 +1,242 @@
1
+ // The `socialAuth()` container's entry point.
2
+ //
3
+ // It runs one emulator per configured provider (the `@emulators/*` packages
4
+ // from vercel-labs/emulate) and puts all of them behind ONE port, routed by
5
+ // `Host` header. That is what lets the component answer at the providers'
6
+ // real endpoints: the daemon owns DNS and TLS for those names and proxies
7
+ // every one of them here.
8
+ //
9
+ // The engine is generic. Everything provider-specific — which hosts to
10
+ // claim, which paths differ from the real ones, which discovery values to
11
+ // correct — arrives as JSON in SPECTEST_AUTH_CONFIG, built by
12
+ // `../social-auth.ts`. Add a provider there, not here.
13
+ //
14
+ // Four things happen to a response on the way out:
15
+ //
16
+ // * discovery — the emulator advertises every endpoint on its own single
17
+ // origin. Real providers spread them over several hosts. We merge the
18
+ // real URLs in.
19
+ // * JWKS — we serve one RSA public key, for every provider.
20
+ // * id_token — we re-issue it: same claims, RS256, our key, and an
21
+ // explicit `iss` where the provider declares one. The Google emulator
22
+ // signs HS256 and serves an empty JWKS, so no client can verify its
23
+ // token. Providers whose issuer differs from their base URL can
24
+ // declare it explicitly. No signature check is needed on the way in: the
25
+ // token comes from an in-process function call, not from the network.
26
+ // * the account picker page — each button gets a `data-testid`, so a
27
+ // browser test has a stable locator.
28
+ //
29
+ // Every request is logged. Per-case service logs reach the dashboard, so
30
+ // the whole OAuth exchange is readable on the case page with no helper API.
31
+
32
+ import { createServer } from "@emulators/core";
33
+ import { SignJWT, decodeJwt, exportJWK } from "jose";
34
+
35
+ const CONFIG = JSON.parse(process.env.SPECTEST_AUTH_CONFIG ?? "{}");
36
+ const PORT = Number(CONFIG.port ?? 4000);
37
+ /** Key id published in the JWKS and stamped on every re-issued id_token. */
38
+ const KID = "spectest-social-auth";
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────
41
+ // Signing key
42
+ //
43
+ // Generated once, at boot — which is before the warm-template snapshot, so
44
+ // every fork of this environment holds the same key and a token minted in a
45
+ // parent stays valid in a child. Never generate it later: after a fork the
46
+ // guest RNG is frozen, and sibling forks would produce identical keys.
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+
49
+ const KEY_PAIR = await crypto.subtle.generateKey(
50
+ {
51
+ name: "RSASSA-PKCS1-v1_5",
52
+ modulusLength: 2048,
53
+ publicExponent: new Uint8Array([1, 0, 1]),
54
+ hash: "SHA-256",
55
+ },
56
+ true,
57
+ ["sign", "verify"],
58
+ );
59
+ const JWKS = {
60
+ keys: [{ ...(await exportJWK(KEY_PAIR.publicKey)), kid: KID, use: "sig", alg: "RS256" }],
61
+ };
62
+
63
+ // ──────────────────────────────────────────────────────────────────────────
64
+ // Providers
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+
67
+ /** host → runtime record. One emulator can claim several hosts. */
68
+ const BY_HOST = new Map();
69
+
70
+ for (const spec of CONFIG.providers ?? []) {
71
+ const mod = await import(spec.module);
72
+ const plugin = mod[spec.pluginExport];
73
+ if (!plugin) {
74
+ throw new Error(
75
+ `social-auth: ${spec.module} has no export ${spec.pluginExport}`,
76
+ );
77
+ }
78
+
79
+ // `port` only feeds the emulator's default base URL, which we always
80
+ // override — nothing listens on it. Each emulator is a fetch handler.
81
+ const { app, store, webhooks } = createServer(plugin, {
82
+ port: PORT,
83
+ baseUrl: spec.baseUrl,
84
+ fallbackUser: spec.fallbackUser,
85
+ });
86
+ plugin.seed?.(store, spec.baseUrl);
87
+ if (spec.seed && mod.seedFromConfig) {
88
+ mod.seedFromConfig(store, spec.baseUrl, spec.seed, webhooks);
89
+ }
90
+
91
+ const runtime = {
92
+ name: spec.name,
93
+ baseUrl: spec.baseUrl,
94
+ oidc: spec.oidc !== false,
95
+ issuer: spec.issuer,
96
+ discovery: spec.discovery ?? {},
97
+ jwksPath: spec.jwksPath ? new RegExp(spec.jwksPath) : null,
98
+ aliases: spec.aliases,
99
+ rewrites: (spec.rewrites ?? []).map((r) => ({ re: new RegExp(r.from), to: r.to })),
100
+ fetch: app.fetch,
101
+ };
102
+ for (const host of spec.hosts) BY_HOST.set(host.toLowerCase(), runtime);
103
+ console.log(`[social-auth] ${spec.name} → ${spec.hosts.join(", ")}`);
104
+ }
105
+
106
+ // ──────────────────────────────────────────────────────────────────────────
107
+ // Response rewriting
108
+ // ──────────────────────────────────────────────────────────────────────────
109
+
110
+ const DISCOVERY_RE = /\/\.well-known\/openid-configuration$/;
111
+
112
+ /** Correct the discovery document: real hosts per endpoint, and RS256,
113
+ * which is what we actually sign with after `reissueIdToken`. */
114
+ async function patchDiscovery(res, provider) {
115
+ const doc = await res.json();
116
+ const patched = {
117
+ ...doc,
118
+ ...provider.discovery,
119
+ id_token_signing_alg_values_supported: ["RS256"],
120
+ };
121
+ return json(patched, res.status);
122
+ }
123
+
124
+ /** Re-issue the id_token on a token response. Claims pass through
125
+ * unchanged except `iss`, which only a provider that cannot derive it
126
+ * from its base URL overrides. */
127
+ async function patchTokenResponse(res, provider) {
128
+ const body = await res.json();
129
+ if (typeof body.id_token !== "string") return json(body, res.status);
130
+
131
+ const claims = decodeJwt(body.id_token);
132
+ if (provider.issuer) claims.iss = provider.issuer;
133
+ body.id_token = await new SignJWT(claims)
134
+ .setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" })
135
+ .sign(KEY_PAIR.privateKey);
136
+ return json(body, res.status);
137
+ }
138
+
139
+ /** Give every account button on the picker page a stable test id, and mark
140
+ * the page itself. The hidden field that identifies the account is named
141
+ * differently per provider, and does not always hold the address — GitHub
142
+ * identifies an account by its login — so `aliases` maps it back. The test
143
+ * id is the account's email on every provider. */
144
+ async function patchPickerPage(res, provider) {
145
+ let html = await res.text();
146
+ html = html.replace(/<form class="user-form"[\s\S]*?<\/form>/g, (form) => {
147
+ const id = /<input type="hidden" name="(?:email|login|sub|username|user_ref)" value="([^"]*)"/.exec(form);
148
+ if (!id) return form;
149
+ const account = provider.aliases?.[id[1]] ?? id[1];
150
+ return form.replace(
151
+ "<button type=\"submit\"",
152
+ `<button type="submit" data-testid="spectest-user-${account}"`,
153
+ );
154
+ });
155
+ html = html.replace("<body", '<body data-spectest-signin="1"');
156
+ return new Response(html, { status: res.status, headers: res.headers });
157
+ }
158
+
159
+ function json(value, status) {
160
+ return new Response(JSON.stringify(value), {
161
+ status,
162
+ headers: { "content-type": "application/json" },
163
+ });
164
+ }
165
+
166
+ // ──────────────────────────────────────────────────────────────────────────
167
+ // Server
168
+ // ──────────────────────────────────────────────────────────────────────────
169
+
170
+ Bun.serve({
171
+ port: PORT,
172
+ // The default 10s kills nothing here, but an authorize page fetched by a
173
+ // cold browser can be slower than it looks. Ingress uses the same margin.
174
+ idleTimeout: 60,
175
+ async fetch(req) {
176
+ const url = new URL(req.url);
177
+
178
+ // Answered on any Host, because the ready check probes the container's
179
+ // own IP and never sends a provider name.
180
+ if (url.pathname === "/healthz") return new Response("ok\n");
181
+
182
+ // The daemon reverse-proxies us and rewrites `Host` to the service-net
183
+ // name, so the provider the client asked for only survives in
184
+ // `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
185
+ const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
186
+ .toLowerCase()
187
+ .replace(/:\d+$/, "");
188
+ const provider = BY_HOST.get(host);
189
+ if (!provider) {
190
+ return new Response(
191
+ `spectest social-auth: no provider claims Host=${JSON.stringify(host)}\n` +
192
+ `claimed hosts: ${[...BY_HOST.keys()].join(", ")}\n`,
193
+ { status: 404, headers: { "content-type": "text/plain" } },
194
+ );
195
+ }
196
+
197
+ // Real path → the path this emulator serves it on. Identity by default,
198
+ // which is also what carries the emulator's own internal endpoints (the
199
+ // picker's POST target) through untouched.
200
+ let path = url.pathname;
201
+ for (const rule of provider.rewrites) {
202
+ if (rule.re.test(path)) {
203
+ path = path.replace(rule.re, rule.to);
204
+ break;
205
+ }
206
+ }
207
+
208
+ let res;
209
+ if (provider.oidc && provider.jwksPath?.test(path)) {
210
+ // Our key, never the emulator's — we re-sign every id_token.
211
+ res = json(JWKS, 200);
212
+ } else {
213
+ const body =
214
+ req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
215
+ // Restore the Host the client used, so an emulator that reads it sees
216
+ // the provider rather than our container.
217
+ const headers = new Headers(req.headers);
218
+ headers.set("host", host);
219
+ const inner = new Request(new URL(path + url.search, provider.baseUrl), {
220
+ method: req.method,
221
+ headers,
222
+ body,
223
+ });
224
+ res = await provider.fetch(inner);
225
+
226
+ const type = res.headers.get("content-type") ?? "";
227
+ if (type.includes("json")) {
228
+ if (DISCOVERY_RE.test(path)) res = await patchDiscovery(res, provider);
229
+ else if (provider.oidc) res = await patchTokenResponse(res, provider);
230
+ } else if (type.includes("text/html")) {
231
+ res = await patchPickerPage(res, provider);
232
+ }
233
+ }
234
+
235
+ console.log(
236
+ `[social-auth] ${provider.name} ${req.method} ${host}${url.pathname} → ${res.status}`,
237
+ );
238
+ return res;
239
+ },
240
+ });
241
+
242
+ console.log(`[social-auth] listening on :${PORT}`);
@@ -61,8 +61,7 @@ export function isTransportError(err) {
61
61
  export function installFetchWrapper() {
62
62
  const original = globalThis.fetch;
63
63
  // SDK internals (the MCP client, its OAuth flow) must not see the
64
- // wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
65
- // body to the end, which never finishes for an SSE stream.
64
+ // wrapper: its response properties and body readers return wrapped values.
66
65
  setRawFetch(original);
67
66
  const wrappedFn = async (input, init) => {
68
67
  const scope = currentInstrumentationScope();
@@ -85,7 +84,12 @@ export function installFetchWrapper() {
85
84
  const contentType = res.headers.get("content-type");
86
85
  const contentLength = () => parseContentLength(res.headers.get("content-length"));
87
86
  const textual = isTextualContentType(contentType);
88
- if (textual === false) {
87
+ const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
88
+ if (init?.captureBody === false || streaming) {
89
+ // Return on headers: an open-ended stream must not be drained (or
90
+ // cloned into an unbounded background buffer) by the recorder.
91
+ }
92
+ else if (textual === false) {
89
93
  responseBody = omittedBody("binary", contentType, contentLength());
90
94
  }
91
95
  else {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { strict as nodeAssert } from "node:assert";
2
- export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
2
+ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
3
3
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
4
4
  export { field } from "./inspect.js";
5
5
  export { annotate } from "./annotate.js";
@@ -300,11 +300,15 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
300
300
  /**
301
301
  * Instrumented `fetch`. In a test each call is recorded on the timeline
302
302
  * and resolves to a {@link WrappedResponse}: reads carry provenance so
303
- * `expect(res.status)` / `expect(await res.json())` nest under the HTTP
303
+ * `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
304
+ * `expect(await res.json())` nest under the HTTP
304
305
  * call. Because the status-line accessors are {@link Carrier}s, a raw
305
306
  * `res.status === 200` is a *type error* — use `res.status.unwrap()` /
306
307
  * `res.unwrap().status`, or assert via `expect`. Outside a test the
307
308
  * result is wrapped the same way, just with no timeline to link to.
309
+ * Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
310
+ * and stream reader values also retain provenance. Use `captureBody: false`
311
+ * for open-ended streams; SSE responses skip recorder body capture automatically.
308
312
  *
309
313
  * (The plain global `fetch` is wrapped the same way at runtime but keeps
310
314
  * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed