@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 +108 -0
- 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/components/emulate/entry.mjs +242 -0
- package/dist/harness/fetch.js +7 -3
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
package/README.md
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,7 +1,54 @@
|
|
|
1
|
+
// Trust for the environment CA, applied per session. Chromium on Linux
|
|
2
|
+
// reads its own root store (NSS), never the container's mounted bundle, so
|
|
3
|
+
// the renderer rejects every certificate the environment mints while Node
|
|
4
|
+
// in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
|
|
5
|
+
// procedure accepts exactly the chains that CA issued for the requested
|
|
6
|
+
// host, and defers to Chromium's own verdict for everything else. An app
|
|
7
|
+
// that installs its own procedure later replaces it, as in production.
|
|
8
|
+
// Kept free of Electron so the unit test can run it under Bun.
|
|
9
|
+
export const ELECTRON_CA_TRUST = String.raw `
|
|
10
|
+
const { X509Certificate } = require('node:crypto');
|
|
11
|
+
function loadEnvironmentCa(path) {
|
|
12
|
+
try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
// request: { hostname, errorCode, certificate: { data, issuerCert? } }.
|
|
16
|
+
// Returns true only for a chain that ends at the CA and names the host.
|
|
17
|
+
function issuedByEnvironmentCa(ca, request) {
|
|
18
|
+
try {
|
|
19
|
+
const leaf = new X509Certificate(request.certificate.data);
|
|
20
|
+
const host = request.hostname;
|
|
21
|
+
const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
|
|
22
|
+
if (!named) return false;
|
|
23
|
+
const chain = [leaf];
|
|
24
|
+
for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
|
|
25
|
+
chain.push(new X509Certificate(c.issuerCert.data));
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < chain.length; i++) {
|
|
28
|
+
const cert = chain[i];
|
|
29
|
+
if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
|
|
30
|
+
const issuer = chain[i + 1] ?? ca;
|
|
31
|
+
if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
1
37
|
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
38
|
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
39
|
export const ELECTRON_CHROME_HOOK = String.raw `
|
|
4
40
|
const electron = require('electron');
|
|
41
|
+
${ELECTRON_CA_TRUST}
|
|
42
|
+
const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
|
|
43
|
+
if (environmentCa) {
|
|
44
|
+
// Every session, including the default one and app-created partitions.
|
|
45
|
+
electron.app.on('session-created', session => {
|
|
46
|
+
session.setCertificateVerifyProc((request, callback) => {
|
|
47
|
+
if (request.errorCode === 0) return callback(0);
|
|
48
|
+
callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
5
52
|
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
53
|
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
54
|
construct(Target, args, NewTarget) {
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { type DesktopApp } from "../desktop.js";
|
|
2
2
|
export interface ElectronOptions {
|
|
3
|
-
/** Project-relative app directory; must contain package.json
|
|
3
|
+
/** Project-relative app directory; must contain package.json. Electron must
|
|
4
|
+
* be declared here or in the context's package.json. */
|
|
4
5
|
appDir?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Image build context, relative to the project root. Default `appDir`.
|
|
8
|
+
* Set it to the repository root when the app is one workspace of a
|
|
9
|
+
* monorepo and resolves packages from above its own directory. The
|
|
10
|
+
* context is copied to `/app`; `installCommand` runs at the context root,
|
|
11
|
+
* `buildCommand` and Electron run in the app directory below it.
|
|
12
|
+
*/
|
|
13
|
+
context?: string;
|
|
5
14
|
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
6
15
|
nodeVersion?: string;
|
|
7
16
|
/** Build the main/preload/renderer output; default npm run build. */
|
|
@@ -31,6 +40,7 @@ export declare function electron(options?: ElectronOptions): {
|
|
|
31
40
|
command: string;
|
|
32
41
|
env: {
|
|
33
42
|
SPECTEST_ELECTRON_ENTRY: string;
|
|
43
|
+
SPECTEST_ELECTRON_APP_DIR: string;
|
|
34
44
|
};
|
|
35
45
|
dependsOn: string[] | undefined;
|
|
36
46
|
ports: number[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import { resolveProjectPath } from "../project-files.js";
|
|
3
4
|
import { desktopApp } from "../desktop.js";
|
|
4
5
|
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
@@ -9,13 +10,14 @@ export const ELECTRON_LAUNCHER = String.raw `
|
|
|
9
10
|
import http from 'node:http';
|
|
10
11
|
import { spawn } from 'node:child_process';
|
|
11
12
|
import { createRequire } from 'node:module';
|
|
12
|
-
const
|
|
13
|
+
const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
|
|
14
|
+
const require = createRequire(appDir + '/package.json');
|
|
13
15
|
const child = spawn(require('electron'), [
|
|
14
16
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
15
17
|
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
16
18
|
'-r', '/spectest-electron-chrome.cjs',
|
|
17
19
|
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
18
|
-
], { cwd:
|
|
20
|
+
], { cwd: appDir, stdio: 'inherit', env: process.env });
|
|
19
21
|
const upstream = req => ({
|
|
20
22
|
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
21
23
|
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
@@ -50,19 +52,41 @@ child.on('error', error => { console.error(error); process.exit(1); });
|
|
|
50
52
|
child.on('exit', code => process.exit(code ?? 1));
|
|
51
53
|
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
52
54
|
`;
|
|
55
|
+
/** Read a package.json and tell whether it declares electron. */
|
|
56
|
+
function declaresElectron(manifest) {
|
|
57
|
+
if (!existsSync(manifest))
|
|
58
|
+
return false;
|
|
59
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
60
|
+
return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
|
|
61
|
+
}
|
|
62
|
+
/** The app directory as a path inside the build context: "." or "a/b". */
|
|
63
|
+
function appPathInContext(appDir, context) {
|
|
64
|
+
const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
|
|
65
|
+
if (rel === "")
|
|
66
|
+
return ".";
|
|
67
|
+
if (rel.startsWith("..") || posix.isAbsolute(rel)) {
|
|
68
|
+
throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
|
|
69
|
+
}
|
|
70
|
+
return rel;
|
|
71
|
+
}
|
|
53
72
|
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
54
73
|
* Like expo(), returns an app handle for the matching context method.
|
|
55
74
|
* Requires Linux-compatible application dependencies and binaries. */
|
|
56
75
|
export function electron(options = {}) {
|
|
57
76
|
const appDir = options.appDir ?? ".";
|
|
77
|
+
const context = options.context ?? appDir;
|
|
78
|
+
const appPath = appPathInContext(appDir, context);
|
|
58
79
|
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
59
80
|
if (!existsSync(manifest))
|
|
60
81
|
throw new Error(`electron(): no package.json in ${appDir}`);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
// A monorepo declares electron in the workspace or at the root; both work
|
|
83
|
+
// because the app resolves `electron` upward from its own directory.
|
|
84
|
+
if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
|
|
85
|
+
throw new Error("electron(): declare electron in the app's dependencies or devDependencies" +
|
|
86
|
+
(context === appDir ? "" : ` (in ${appDir} or in the context ${context})`));
|
|
64
87
|
}
|
|
65
|
-
|
|
88
|
+
// The install runs at the context root, so that is where the lockfile counts.
|
|
89
|
+
const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
|
|
66
90
|
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
67
91
|
if (!/^[\w.-]+$/.test(node))
|
|
68
92
|
throw new Error("electron(): invalid Node image tag");
|
|
@@ -71,18 +95,22 @@ export function electron(options = {}) {
|
|
|
71
95
|
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
72
96
|
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
73
97
|
}
|
|
98
|
+
// Electron can be hoisted to the context root, so find its install script
|
|
99
|
+
// through Node's resolution from the app directory instead of a fixed path.
|
|
100
|
+
const inApp = appPath === "." ? "" : `cd ${appPath} && `;
|
|
101
|
+
const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
|
|
74
102
|
return {
|
|
75
103
|
image: {
|
|
76
104
|
type: "dockerfile",
|
|
77
|
-
context
|
|
105
|
+
context,
|
|
78
106
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
79
107
|
content: `FROM node:${node}
|
|
80
108
|
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
81
109
|
WORKDIR /app
|
|
82
110
|
COPY . .
|
|
83
111
|
RUN ${install}
|
|
84
|
-
RUN
|
|
85
|
-
RUN ${build}
|
|
112
|
+
RUN ${fetchElectron}
|
|
113
|
+
RUN ${inApp}${build}
|
|
86
114
|
`,
|
|
87
115
|
},
|
|
88
116
|
files: [
|
|
@@ -90,7 +118,11 @@ RUN ${build}
|
|
|
90
118
|
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
91
119
|
],
|
|
92
120
|
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
93
|
-
env: {
|
|
121
|
+
env: {
|
|
122
|
+
...options.env,
|
|
123
|
+
SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
|
|
124
|
+
SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
|
|
125
|
+
},
|
|
94
126
|
dependsOn: options.dependsOn,
|
|
95
127
|
ports: [9223],
|
|
96
128
|
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|
|
@@ -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}`);
|
package/dist/harness/fetch.js
CHANGED
|
@@ -61,8 +61,7 @@ export function isTransportError(err) {
|
|
|
61
61
|
export function installFetchWrapper() {
|
|
62
62
|
const original = globalThis.fetch;
|
|
63
63
|
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
64
|
-
// wrapper:
|
|
65
|
-
// body to the end, which never finishes for an SSE stream.
|
|
64
|
+
// wrapper: its response properties and body readers return wrapped values.
|
|
66
65
|
setRawFetch(original);
|
|
67
66
|
const wrappedFn = async (input, init) => {
|
|
68
67
|
const scope = currentInstrumentationScope();
|
|
@@ -85,7 +84,12 @@ export function installFetchWrapper() {
|
|
|
85
84
|
const contentType = res.headers.get("content-type");
|
|
86
85
|
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
87
86
|
const textual = isTextualContentType(contentType);
|
|
88
|
-
|
|
87
|
+
const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
88
|
+
if (init?.captureBody === false || streaming) {
|
|
89
|
+
// Return on headers: an open-ended stream must not be drained (or
|
|
90
|
+
// cloned into an unbounded background buffer) by the recorder.
|
|
91
|
+
}
|
|
92
|
+
else if (textual === false) {
|
|
89
93
|
responseBody = omittedBody("binary", contentType, contentLength());
|
|
90
94
|
}
|
|
91
95
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { strict as nodeAssert } from "node:assert";
|
|
2
|
-
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
|
|
2
|
+
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
3
3
|
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
4
4
|
export { field } from "./inspect.js";
|
|
5
5
|
export { annotate } from "./annotate.js";
|
|
@@ -300,11 +300,15 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
300
300
|
/**
|
|
301
301
|
* Instrumented `fetch`. In a test each call is recorded on the timeline
|
|
302
302
|
* and resolves to a {@link WrappedResponse}: reads carry provenance so
|
|
303
|
-
* `expect(res.status)
|
|
303
|
+
* `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
|
|
304
|
+
* `expect(await res.json())` nest under the HTTP
|
|
304
305
|
* call. Because the status-line accessors are {@link Carrier}s, a raw
|
|
305
306
|
* `res.status === 200` is a *type error* — use `res.status.unwrap()` /
|
|
306
307
|
* `res.unwrap().status`, or assert via `expect`. Outside a test the
|
|
307
308
|
* result is wrapped the same way, just with no timeline to link to.
|
|
309
|
+
* Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
|
|
310
|
+
* and stream reader values also retain provenance. Use `captureBody: false`
|
|
311
|
+
* for open-ended streams; SSE responses skip recorder body capture automatically.
|
|
308
312
|
*
|
|
309
313
|
* (The plain global `fetch` is wrapped the same way at runtime but keeps
|
|
310
314
|
* the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
|