@specific.dev/spectest 0.88.2 → 0.88.3

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,68 @@
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
+ ## Electron apps
19
+
20
+ Use `electron()` to build and run a real Linux Electron app under Xvfb, with
21
+ its main process, preload, IPC and renderer. Spectest supplies the container
22
+ setup; no custom Dockerfile is needed.
23
+
24
+ Create `spectest/index.ts`:
25
+
26
+ ```ts
27
+ import { defineEnvironment } from "@specific.dev/spectest";
28
+ import { electron } from "@specific.dev/spectest/components";
29
+
30
+ export const env = defineEnvironment({
31
+ name: "desktop-app",
32
+ services: { app: electron() },
33
+ });
34
+
35
+ export default env.project();
36
+ ```
37
+
38
+ Create `spectest/tests/notes.ts`, using your app's labels:
39
+
40
+ ```ts
41
+ import { expect } from "@specific.dev/spectest";
42
+ import { env } from "../index";
43
+
44
+ env.test("Save a note", async (ctx) => {
45
+ const app = await ctx.desktop(ctx.svc.app);
46
+ await app.getByRole("textbox", { name: "New note" }).fill("Hello desktop");
47
+ await app.getByRole("button", { name: "Add note", exact: true }).click();
48
+ await expect(app.getByRole("listitem")).toHaveText(["Hello desktop"]);
49
+ });
50
+ ```
51
+
52
+ Run `spectest test .` from the project root. The dashboard records desktop
53
+ interactions and replays the app with window chrome derived from its Electron
54
+ configuration. Child tests using `dependsOn` inherit the live app state,
55
+ including unsaved input, in their own isolated environment.
56
+
57
+ The app must declare Electron in `package.json`. Defaults are `npm ci` with a
58
+ package lock (otherwise `npm install`), `npm run build`, and the package's
59
+ `main` entry. Use `appDir`, `installCommand`, `buildCommand`, `entry`, and
60
+ `nodeVersion` to customize the build. `env` supplies runtime variables;
61
+ `dependsOn` waits for backend services in the same environment.
62
+
63
+ Applications need Linux-compatible dependencies and binaries. Native OS dialogs
64
+ and macOS-specific APIs are outside this basic support. Replays capture the
65
+ renderer DOM from acquisition onward, not native OS surfaces.
66
+
67
+ Run `spectest docs /components/electron` for the full configuration reference,
68
+ backend setup, session behavior, and replay scope (CLI 0.2.124 or later).
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.88.2",
3
+ "version": "0.88.3",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",