@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 +68 -0
- package/dist/components/emulate/entry.mjs +242 -0
- package/package.json +1 -1
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}`);
|