@specific.dev/spectest 0.41.0 → 0.44.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/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/apple.d.ts +47 -0
- package/dist/components/apple.js +59 -0
- package/dist/components/emulate/service.d.ts +126 -0
- package/dist/components/emulate/service.js +153 -0
- package/dist/components/github.d.ts +52 -0
- package/dist/components/github.js +90 -0
- package/dist/components/google.d.ts +57 -0
- package/dist/components/google.js +92 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +5 -0
- package/dist/components/microsoft.d.ts +55 -0
- package/dist/components/microsoft.js +76 -0
- package/dist/components/okta.d.ts +51 -0
- package/dist/components/okta.js +78 -0
- package/dist/daemon.js +76 -46
- package/dist/index.d.ts +55 -13
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -1
- package/src/browser.ts +88 -33
- package/src/components/apple.ts +65 -0
- package/src/components/emulate/entry.mjs +244 -0
- package/src/components/emulate/service.ts +235 -0
- package/src/components/github.ts +96 -0
- package/src/components/google.ts +104 -0
- package/src/components/index.ts +14 -0
- package/src/components/microsoft.ts +89 -0
- package/src/components/okta.ts +93 -0
- package/src/daemon.ts +87 -39
- package/src/index.ts +55 -13
- package/src/mobile.ts +10 -5
- package/src/recorder.ts +10 -0
package/dist/browser.d.ts
CHANGED
|
@@ -60,6 +60,14 @@ export interface BrowserSessionRecorder {
|
|
|
60
60
|
* the dashboard can link a browser event to its replay player.
|
|
61
61
|
*/
|
|
62
62
|
readonly sessionId: string;
|
|
63
|
+
/**
|
|
64
|
+
* Author-given name of this session (`ctx.browser("alice")`), absent
|
|
65
|
+
* for the default unnamed one. Echoed onto every op's event so the
|
|
66
|
+
* step list can say WHICH browser acted without having to resolve the
|
|
67
|
+
* session record — which the CLI's failure detail can't do (it renders
|
|
68
|
+
* from the persisted events alone, with no replay bundle).
|
|
69
|
+
*/
|
|
70
|
+
readonly sessionName?: string;
|
|
63
71
|
/** Called for each drained chunk of rrweb events. */
|
|
64
72
|
recordStep(step: BrowserSessionStep): void;
|
|
65
73
|
/** Optional: called whenever `Browser.goto(url)` is invoked. */
|
|
@@ -302,6 +310,9 @@ export declare function openBrowser(opts?: BrowserOptions): Promise<Browser>;
|
|
|
302
310
|
* the same object.
|
|
303
311
|
*/
|
|
304
312
|
export declare function openMobileBackend(opts?: BrowserOptions): Promise<MobileBackend>;
|
|
313
|
+
/** Registry key for a named mobile session. NUL can't occur in a name or
|
|
314
|
+
* a URL, so the two halves can never run together ambiguously. */
|
|
315
|
+
export declare function mobileKey(name: string, url: string): string;
|
|
305
316
|
/**
|
|
306
317
|
* What acquiring a persistent session returns. `detach` is the test-end
|
|
307
318
|
* hook (final rrweb drain, stop writing to this test's recorder, keep the
|
|
@@ -318,17 +329,19 @@ export interface PersistentBrowser {
|
|
|
318
329
|
detach(): Promise<void>;
|
|
319
330
|
}
|
|
320
331
|
/**
|
|
321
|
-
* Acquire
|
|
322
|
-
*
|
|
323
|
-
* test DAG shares
|
|
324
|
-
*
|
|
332
|
+
* Acquire the persistent desktop browser called `name` (creating it on
|
|
333
|
+
* first use). There is one per name — `ctx.browser()` uses the default
|
|
334
|
+
* name `""` — so a test DAG shares each named browsing session along each
|
|
335
|
+
* branch, and two names are two independent users. The first call for a
|
|
336
|
+
* name wins its options; later calls attach to the existing view as-is.
|
|
325
337
|
*/
|
|
326
|
-
export declare function acquirePersistentBrowser(opts?: BrowserOptions): Promise<PersistentBrowser>;
|
|
338
|
+
export declare function acquirePersistentBrowser(name?: string, opts?: BrowserOptions): Promise<PersistentBrowser>;
|
|
327
339
|
/**
|
|
328
|
-
* Acquire the persistent mobile session for an app URL (one
|
|
329
|
-
*
|
|
340
|
+
* Acquire the persistent mobile session for an app URL under `name` (one
|
|
341
|
+
* per name per app; `ctx.mobile(app)` uses the default name `""`). A fresh
|
|
342
|
+
* session navigates to the app; an attach continues on the live page.
|
|
330
343
|
*/
|
|
331
|
-
export declare function acquirePersistentMobileBackend(url: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
|
|
344
|
+
export declare function acquirePersistentMobileBackend(url: string, name: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
|
|
332
345
|
export interface RecordableFields {
|
|
333
346
|
url: string;
|
|
334
347
|
selector: string;
|
package/dist/browser.js
CHANGED
|
@@ -872,16 +872,45 @@ export async function openMobileBackend(opts = {}) {
|
|
|
872
872
|
// ────────────────────────────────────────────────────────────────────────
|
|
873
873
|
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
874
874
|
// ────────────────────────────────────────────────────────────────────────
|
|
875
|
-
//
|
|
875
|
+
// Long-lived sessions, keyed by NAME: one desktop browser per name, one
|
|
876
|
+
// mobile session per (name, app URL). The default name is `""` — what
|
|
877
|
+
// `ctx.browser()` / `ctx.mobile(app)` use — so an unnamed project keeps
|
|
878
|
+
// exactly the one-session-per-device behaviour it had before names
|
|
879
|
+
// existed. A name is the key and nothing else: two names are two
|
|
880
|
+
// BrowserContexts in the one Chromium, i.e. two independent cookie jars
|
|
881
|
+
// and localStorage, which is what modelling two users needs.
|
|
882
|
+
//
|
|
876
883
|
// Module state lives in daemon memory, so it forks with the snapshot the
|
|
877
|
-
// same way fake `state` and TEST_DATA do: a test's
|
|
878
|
-
//
|
|
884
|
+
// same way fake `state` and TEST_DATA do: a test's browsers — their live
|
|
885
|
+
// pages, cookies, localStorage, in-memory SPA state — are captured in the
|
|
879
886
|
// post-test snapshot and inherited by `dependsOn` children, while sibling
|
|
880
887
|
// forks never see each other's sessions. That's what lets a child test
|
|
881
888
|
// continue where its parent left off (e.g. already signed in) instead of
|
|
882
|
-
// re-navigating and re-authenticating.
|
|
883
|
-
|
|
889
|
+
// re-navigating and re-authenticating. The name is a plain string, so it
|
|
890
|
+
// keys the same session on both sides of a fork.
|
|
891
|
+
const SHARED_BROWSERS = new Map();
|
|
884
892
|
const SHARED_MOBILE = new Map();
|
|
893
|
+
/**
|
|
894
|
+
* Ceiling on live persistent sessions of one kind (desktop / mobile).
|
|
895
|
+
* Every session is a Chromium BrowserContext that rides every snapshot
|
|
896
|
+
* from here down the DAG, so a test that mints names in a loop
|
|
897
|
+
* (`ctx.browser(userId)`) would grow the VM's memory floor for the rest
|
|
898
|
+
* of the run. Failing loudly at a sane count beats a wedged guest.
|
|
899
|
+
*/
|
|
900
|
+
const MAX_PERSISTENT_SESSIONS = 8;
|
|
901
|
+
/** Registry key for a named mobile session. NUL can't occur in a name or
|
|
902
|
+
* a URL, so the two halves can never run together ambiguously. */
|
|
903
|
+
export function mobileKey(name, url) {
|
|
904
|
+
return `${name}\u0000${url}`;
|
|
905
|
+
}
|
|
906
|
+
/** Guard the session cap, naming the offender and what to do about it. */
|
|
907
|
+
function checkSessionCap(kind, live, name) {
|
|
908
|
+
if (live < MAX_PERSISTENT_SESSIONS)
|
|
909
|
+
return;
|
|
910
|
+
throw new Error(`too many ${kind} sessions: ${MAX_PERSISTENT_SESSIONS} are already open and "${name}" would be another. ` +
|
|
911
|
+
`Each named session is a live browser captured in every snapshot from here on — name them for the ` +
|
|
912
|
+
`roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
|
|
913
|
+
}
|
|
885
914
|
async function newHolder(width, height, device) {
|
|
886
915
|
const context = await newViewContext(width, height, device);
|
|
887
916
|
const spawned = await spawnPage(context);
|
|
@@ -941,58 +970,65 @@ async function attachReset(holder) {
|
|
|
941
970
|
}
|
|
942
971
|
}
|
|
943
972
|
/**
|
|
944
|
-
* Acquire
|
|
945
|
-
*
|
|
946
|
-
* test DAG shares
|
|
947
|
-
*
|
|
973
|
+
* Acquire the persistent desktop browser called `name` (creating it on
|
|
974
|
+
* first use). There is one per name — `ctx.browser()` uses the default
|
|
975
|
+
* name `""` — so a test DAG shares each named browsing session along each
|
|
976
|
+
* branch, and two names are two independent users. The first call for a
|
|
977
|
+
* name wins its options; later calls attach to the existing view as-is.
|
|
948
978
|
*/
|
|
949
|
-
export async function acquirePersistentBrowser(opts = {}) {
|
|
979
|
+
export async function acquirePersistentBrowser(name = "", opts = {}) {
|
|
950
980
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
951
|
-
let
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
981
|
+
let holder = SHARED_BROWSERS.get(name);
|
|
982
|
+
const attached = holder !== undefined;
|
|
983
|
+
if (holder) {
|
|
984
|
+
await attachReset(holder);
|
|
955
985
|
}
|
|
956
986
|
else {
|
|
957
|
-
|
|
987
|
+
checkSessionCap("browser", SHARED_BROWSERS.size, name);
|
|
988
|
+
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device);
|
|
989
|
+
SHARED_BROWSERS.set(name, holder);
|
|
958
990
|
}
|
|
959
|
-
const
|
|
960
|
-
const { backend, detach } = buildBackend(
|
|
991
|
+
const view = holder;
|
|
992
|
+
const { backend, detach } = buildBackend(view, opts.recorder ?? null, {
|
|
961
993
|
persistent: true,
|
|
962
994
|
onDestroy: () => {
|
|
963
|
-
if (
|
|
964
|
-
|
|
995
|
+
if (SHARED_BROWSERS.get(name) === view)
|
|
996
|
+
SHARED_BROWSERS.delete(name);
|
|
965
997
|
},
|
|
966
998
|
});
|
|
967
999
|
// Fresh session only: an attached view already carries the init script on
|
|
968
1000
|
// its (forked) holder, and first-call-wins means a later call's options
|
|
969
1001
|
// don't retroactively apply.
|
|
970
1002
|
if (!attached && opts.initScript !== undefined) {
|
|
971
|
-
await installInitScript(
|
|
1003
|
+
await installInitScript(view, opts.initScript);
|
|
972
1004
|
}
|
|
973
1005
|
if (!attached && opts.url !== undefined)
|
|
974
1006
|
await backend.goto(opts.url);
|
|
975
1007
|
return { browser: backend, attached, detach };
|
|
976
1008
|
}
|
|
977
1009
|
/**
|
|
978
|
-
* Acquire the persistent mobile session for an app URL (one
|
|
979
|
-
*
|
|
1010
|
+
* Acquire the persistent mobile session for an app URL under `name` (one
|
|
1011
|
+
* per name per app; `ctx.mobile(app)` uses the default name `""`). A fresh
|
|
1012
|
+
* session navigates to the app; an attach continues on the live page.
|
|
980
1013
|
*/
|
|
981
|
-
export async function acquirePersistentMobileBackend(url, recorder, initScript) {
|
|
982
|
-
const
|
|
1014
|
+
export async function acquirePersistentMobileBackend(url, name, recorder, initScript) {
|
|
1015
|
+
const key = mobileKey(name, url);
|
|
1016
|
+
const existing = SHARED_MOBILE.get(key);
|
|
1017
|
+
if (!existing)
|
|
1018
|
+
checkSessionCap("mobile", SHARED_MOBILE.size, name);
|
|
983
1019
|
const holder = existing ??
|
|
984
1020
|
(await newHolder(LATEST_IPHONE.viewport.width, LATEST_IPHONE.viewport.height, LATEST_IPHONE));
|
|
985
1021
|
if (existing) {
|
|
986
1022
|
await attachReset(holder);
|
|
987
1023
|
}
|
|
988
1024
|
else {
|
|
989
|
-
SHARED_MOBILE.set(
|
|
1025
|
+
SHARED_MOBILE.set(key, holder);
|
|
990
1026
|
}
|
|
991
1027
|
const { backend, detach } = buildBackend(holder, recorder, {
|
|
992
1028
|
persistent: true,
|
|
993
1029
|
onDestroy: () => {
|
|
994
|
-
if (SHARED_MOBILE.get(
|
|
995
|
-
SHARED_MOBILE.delete(
|
|
1030
|
+
if (SHARED_MOBILE.get(key) === holder)
|
|
1031
|
+
SHARED_MOBILE.delete(key);
|
|
996
1032
|
},
|
|
997
1033
|
});
|
|
998
1034
|
if (!existing) {
|
|
@@ -1069,6 +1105,18 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1069
1105
|
// when rrweb's load-deferred full snapshot is most likely to be
|
|
1070
1106
|
// missing from the chunk we're about to drain (see `drainExpr`).
|
|
1071
1107
|
let lastDrainUrl = null;
|
|
1108
|
+
/** Session provenance stamped on every recorded op: which replay player
|
|
1109
|
+
* the step belongs to, which named browser it acted on, and where in
|
|
1110
|
+
* the player to seek (`endT`, in rrweb's clock). */
|
|
1111
|
+
function sessionFields(endT) {
|
|
1112
|
+
if (!recorder)
|
|
1113
|
+
return {};
|
|
1114
|
+
return {
|
|
1115
|
+
sessionId: recorder.sessionId,
|
|
1116
|
+
sessionTimestamp: endT,
|
|
1117
|
+
...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1072
1120
|
async function drain(action) {
|
|
1073
1121
|
if (!holder.recordingInstalled || !recorder || recordingEnded)
|
|
1074
1122
|
return;
|
|
@@ -1112,9 +1160,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1112
1160
|
const seq = recordBrowser({
|
|
1113
1161
|
action,
|
|
1114
1162
|
...fields,
|
|
1115
|
-
...(
|
|
1116
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1117
|
-
: {}),
|
|
1163
|
+
...sessionFields(endT),
|
|
1118
1164
|
durationMs: endT - t,
|
|
1119
1165
|
}, resv);
|
|
1120
1166
|
await drain(action);
|
|
@@ -1132,9 +1178,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1132
1178
|
recordBrowser({
|
|
1133
1179
|
action,
|
|
1134
1180
|
...fields,
|
|
1135
|
-
...(
|
|
1136
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1137
|
-
: {}),
|
|
1181
|
+
...sessionFields(endT),
|
|
1138
1182
|
durationMs: endT - t,
|
|
1139
1183
|
error: e?.message ?? String(err),
|
|
1140
1184
|
}, resv);
|
|
@@ -1459,9 +1503,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1459
1503
|
const seq = recordBrowser({
|
|
1460
1504
|
action,
|
|
1461
1505
|
...fields,
|
|
1462
|
-
...(
|
|
1463
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1464
|
-
: {}),
|
|
1506
|
+
...sessionFields(endT),
|
|
1465
1507
|
durationMs: waitedMs,
|
|
1466
1508
|
...(error ? { error } : {}),
|
|
1467
1509
|
}, reserveBackdated(waitedMs));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProviderOptions } from "./emulate/service.js";
|
|
2
|
+
export type AppleOptions = ProviderOptions;
|
|
3
|
+
/**
|
|
4
|
+
* Apple, answering at its real endpoints. Drop into
|
|
5
|
+
* `environment.services`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { apple } from "@specific.dev/spectest/components";
|
|
9
|
+
*
|
|
10
|
+
* services: {
|
|
11
|
+
* apple: apple({
|
|
12
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
13
|
+
* client: {
|
|
14
|
+
* clientId: "com.example.app",
|
|
15
|
+
* clientSecret: "client-assertion",
|
|
16
|
+
* redirectUris: ["https://app.test/callback/apple"],
|
|
17
|
+
* },
|
|
18
|
+
* }),
|
|
19
|
+
* app: { …, dependsOn: ["apple"] },
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The app keeps its production configuration — it sends a browser to
|
|
24
|
+
* `https://appleid.apple.com/auth/authorize` and exchanges the code at
|
|
25
|
+
* `https://appleid.apple.com/auth/token`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function apple(opts?: AppleOptions): {
|
|
28
|
+
image: {
|
|
29
|
+
type: "dockerfile";
|
|
30
|
+
content: string;
|
|
31
|
+
};
|
|
32
|
+
command: string;
|
|
33
|
+
files: {
|
|
34
|
+
path: string;
|
|
35
|
+
content: string;
|
|
36
|
+
}[];
|
|
37
|
+
env: {
|
|
38
|
+
SPECTEST_AUTH_CONFIG: string;
|
|
39
|
+
};
|
|
40
|
+
ports: number[];
|
|
41
|
+
readyCheck: {
|
|
42
|
+
type: "http";
|
|
43
|
+
port: number;
|
|
44
|
+
path: string;
|
|
45
|
+
timeoutSecs: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `apple()` — Apple, emulated inside the environment, answering at its real
|
|
2
|
+
// endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Apple: discovery, the account picker, the
|
|
5
|
+
// token exchange, the refresh grant, and revocation, all on
|
|
6
|
+
// `appleid.apple.com`.
|
|
7
|
+
//
|
|
8
|
+
// Apple publishes **no userinfo endpoint** — the real one does not either,
|
|
9
|
+
// because for Apple the id_token is the whole profile. An app that wants to
|
|
10
|
+
// re-read an account uses the refresh grant, which is the only
|
|
11
|
+
// server-to-server call Apple offers.
|
|
12
|
+
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
13
|
+
function spec(users, opts) {
|
|
14
|
+
return {
|
|
15
|
+
name: "apple",
|
|
16
|
+
module: "@emulators/apple",
|
|
17
|
+
pluginExport: "applePlugin",
|
|
18
|
+
baseUrl: "https://appleid.apple.com",
|
|
19
|
+
// Every Apple endpoint is on this one host, so no path or discovery
|
|
20
|
+
// correction is needed.
|
|
21
|
+
hosts: ["appleid.apple.com"],
|
|
22
|
+
jwksPath: "^/auth/keys$",
|
|
23
|
+
fallbackUser: { login: users[0].email, id: 1, scopes: ["openid", "email", "name"] },
|
|
24
|
+
seed: {
|
|
25
|
+
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
26
|
+
// Apple authenticates the client with a signed assertion rather than
|
|
27
|
+
// a shared secret, so the registration carries a team id instead.
|
|
28
|
+
...oauthClients(opts.client, { team_id: "SPECTEST" }),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Apple, answering at its real endpoints. Drop into
|
|
34
|
+
* `environment.services`:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { apple } from "@specific.dev/spectest/components";
|
|
38
|
+
*
|
|
39
|
+
* services: {
|
|
40
|
+
* apple: apple({
|
|
41
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
42
|
+
* client: {
|
|
43
|
+
* clientId: "com.example.app",
|
|
44
|
+
* clientSecret: "client-assertion",
|
|
45
|
+
* redirectUris: ["https://app.test/callback/apple"],
|
|
46
|
+
* },
|
|
47
|
+
* }),
|
|
48
|
+
* app: { …, dependsOn: ["apple"] },
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* The app keeps its production configuration — it sends a browser to
|
|
53
|
+
* `https://appleid.apple.com/auth/authorize` and exchanges the code at
|
|
54
|
+
* `https://appleid.apple.com/auth/token`.
|
|
55
|
+
*/
|
|
56
|
+
export function apple(opts = {}) {
|
|
57
|
+
const users = resolveUsers("apple", opts.users);
|
|
58
|
+
return emulatorService(spec(users, opts));
|
|
59
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** A person who can sign in. Seeded into the provider's account picker. */
|
|
2
|
+
export interface ProviderUser {
|
|
3
|
+
/** Primary address. Also the account's test id on the picker page. */
|
|
4
|
+
email: string;
|
|
5
|
+
/** Display name. */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** Avatar URL, passed through to the profile claims. */
|
|
8
|
+
picture?: string;
|
|
9
|
+
/** GitHub username. Defaults to the local part of `email`. */
|
|
10
|
+
login?: string;
|
|
11
|
+
}
|
|
12
|
+
/** An OAuth client, as registered with the real provider. Declaring one
|
|
13
|
+
* makes the environment **stricter**: `clientId`, `clientSecret` and
|
|
14
|
+
* `redirectUris` are then all validated, exactly as in production. Omit it
|
|
15
|
+
* and any client is accepted. */
|
|
16
|
+
export interface OAuthClient {
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
/** Callback URLs the app is allowed to return to. A request to any other
|
|
20
|
+
* one is refused, which is what makes a misconfigured callback fail here
|
|
21
|
+
* rather than in production. */
|
|
22
|
+
redirectUris?: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The accounts every provider offers when a component declares none.
|
|
26
|
+
*
|
|
27
|
+
* Two, not one: a test that asserts it signed in as Alice only means
|
|
28
|
+
* something if the provider had somebody else to return. Both carry a
|
|
29
|
+
* `login`, so GitHub gets a handle without the caller supplying one.
|
|
30
|
+
*
|
|
31
|
+
* `example.com` is reserved for exactly this (RFC 2606), so no default
|
|
32
|
+
* account can ever collide with a real address.
|
|
33
|
+
*/
|
|
34
|
+
export declare const DEFAULT_USERS: readonly ProviderUser[];
|
|
35
|
+
/** Options every provider component takes. */
|
|
36
|
+
export interface ProviderOptions {
|
|
37
|
+
/**
|
|
38
|
+
* The accounts offered on the provider's picker page. Defaults to
|
|
39
|
+
* **Alice Example** (`alice@example.com`) and **Bob Example**
|
|
40
|
+
* (`bob@example.com`) — enough to sign in as somebody, and to tell two
|
|
41
|
+
* people apart, without declaring anything.
|
|
42
|
+
*/
|
|
43
|
+
users?: ProviderUser[];
|
|
44
|
+
/** The app's OAuth client. Omit it and any client id, secret and callback
|
|
45
|
+
* URL are accepted. */
|
|
46
|
+
client?: OAuthClient;
|
|
47
|
+
}
|
|
48
|
+
/** What `entry.mjs` consumes. Everything provider-specific lives in the
|
|
49
|
+
* per-provider module; the script itself is generic. */
|
|
50
|
+
export interface ProviderSpec {
|
|
51
|
+
name: string;
|
|
52
|
+
module: string;
|
|
53
|
+
pluginExport: string;
|
|
54
|
+
/** Base URL the emulator advertises — the provider's real sign-in host. */
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
/** Every hostname routed to this emulator. */
|
|
57
|
+
hosts: string[];
|
|
58
|
+
/** False for a plain OAuth 2.0 provider with no id_token (GitHub). */
|
|
59
|
+
oidc?: boolean;
|
|
60
|
+
/** Issuer to stamp on the re-issued id_token, where the emulator cannot
|
|
61
|
+
* derive it from `baseUrl`. */
|
|
62
|
+
issuer?: string;
|
|
63
|
+
/** Values merged over the emulator's discovery document. */
|
|
64
|
+
discovery?: Record<string, string>;
|
|
65
|
+
/** Path (as a regex source) answered with our own JWKS. */
|
|
66
|
+
jwksPath?: string;
|
|
67
|
+
/** Picker-page identifier → the account's email, where the provider
|
|
68
|
+
* identifies an account by something else. Keeps the account's test id
|
|
69
|
+
* the same on every provider. */
|
|
70
|
+
aliases?: Record<string, string>;
|
|
71
|
+
/** Real path → the path this emulator serves it on. Identity elsewhere. */
|
|
72
|
+
rewrites?: {
|
|
73
|
+
from: string;
|
|
74
|
+
to: string;
|
|
75
|
+
}[];
|
|
76
|
+
fallbackUser?: {
|
|
77
|
+
login: string;
|
|
78
|
+
id: number;
|
|
79
|
+
scopes: string[];
|
|
80
|
+
};
|
|
81
|
+
seed?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
/** The service definition for one provider: the container, plus the claim
|
|
84
|
+
* on its domains (one certificate carrying every hostname as a SAN, and a
|
|
85
|
+
* proxy route each). One `certificate(...)` rather than per-host `tls`
|
|
86
|
+
* entries, for the reason `aws()` gives — `tls` mints a separate leaf per
|
|
87
|
+
* entry, and a provider can claim four names. */
|
|
88
|
+
export declare function emulatorService(spec: ProviderSpec): {
|
|
89
|
+
image: {
|
|
90
|
+
type: "dockerfile";
|
|
91
|
+
content: string;
|
|
92
|
+
};
|
|
93
|
+
command: string;
|
|
94
|
+
files: {
|
|
95
|
+
path: string;
|
|
96
|
+
content: string;
|
|
97
|
+
}[];
|
|
98
|
+
env: {
|
|
99
|
+
SPECTEST_AUTH_CONFIG: string;
|
|
100
|
+
};
|
|
101
|
+
ports: number[];
|
|
102
|
+
readyCheck: {
|
|
103
|
+
type: "http";
|
|
104
|
+
port: number;
|
|
105
|
+
path: string;
|
|
106
|
+
timeoutSecs: number;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/** The accounts to seed: what the caller declared, or {@link DEFAULT_USERS}.
|
|
110
|
+
* An explicitly empty list is a mistake, not a request for none — a
|
|
111
|
+
* provider with no accounts can never sign anyone in. */
|
|
112
|
+
export declare function resolveUsers(component: string, users: ProviderUser[] | undefined): ProviderUser[];
|
|
113
|
+
export declare function localPart(email: string): string;
|
|
114
|
+
export declare function givenName(user: ProviderUser): string | undefined;
|
|
115
|
+
export declare function familyName(user: ProviderUser): string | undefined;
|
|
116
|
+
/** The `oauth_clients` seed entry every provider but GitHub uses. */
|
|
117
|
+
export declare function oauthClients(client: OAuthClient | undefined, extra?: Record<string, unknown>): {
|
|
118
|
+
oauth_clients?: undefined;
|
|
119
|
+
} | {
|
|
120
|
+
oauth_clients: {
|
|
121
|
+
client_id: string;
|
|
122
|
+
client_secret: string;
|
|
123
|
+
name: string;
|
|
124
|
+
redirect_uris: string[];
|
|
125
|
+
}[];
|
|
126
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Shared runtime behind the third-party provider components — `google()`,
|
|
2
|
+
// `github()`, `apple()`, `microsoft()`, `okta()`.
|
|
3
|
+
//
|
|
4
|
+
// Each of those is one container answering at that provider's **real**
|
|
5
|
+
// endpoints: the component claims the provider's domains, so DNS, a leaf
|
|
6
|
+
// certificate from the in-VM root CA, and a reverse proxy all point at it.
|
|
7
|
+
// The app under test keeps its production configuration and never learns it
|
|
8
|
+
// is under test.
|
|
9
|
+
//
|
|
10
|
+
// A provider component is therefore a table, not a program. It names the
|
|
11
|
+
// hosts it claims, the emulator package behind it, the handful of paths
|
|
12
|
+
// where that emulator differs from the real service, and how to seed it.
|
|
13
|
+
// Everything else lives here and in `entry.mjs`.
|
|
14
|
+
//
|
|
15
|
+
// ── Internal notes (deliberately not in the user-facing docs) ─────────────
|
|
16
|
+
//
|
|
17
|
+
// The emulators come from vercel-labs/emulate (Apache-2.0), used through
|
|
18
|
+
// their published `@emulators/*` packages and pinned. They are NOT SDK
|
|
19
|
+
// dependencies: they install into the image below. That matters —
|
|
20
|
+
// `sdk/package.json` is in the base-snapshot discriminator, so a dependency
|
|
21
|
+
// here would force a base rebuild and one cold start for every project on
|
|
22
|
+
// the box, for components most projects never use.
|
|
23
|
+
//
|
|
24
|
+
// Each emulator serves every one of its endpoints on ONE origin. Real
|
|
25
|
+
// providers spread them over several hosts, and a few paths differ.
|
|
26
|
+
// `entry.mjs` routes by Host header, applies the per-provider path table,
|
|
27
|
+
// and corrects the discovery document. It also re-issues every id_token
|
|
28
|
+
// with one RSA key it generates at boot, and serves that key at the
|
|
29
|
+
// provider's real JWKS URL. That single step covers three separate faults:
|
|
30
|
+
// the Google emulator signs HS256 and publishes an empty JWKS (so nothing
|
|
31
|
+
// can verify its token), the Microsoft one cannot put the tenant in its
|
|
32
|
+
// issuer, and any future provider whose issuer is not its base URL is
|
|
33
|
+
// handled in advance.
|
|
34
|
+
//
|
|
35
|
+
// The Dockerfile is deliberately CONSTANT — every provider package is
|
|
36
|
+
// installed whether or not this component is the one using it. Two reasons:
|
|
37
|
+
// one image is shared by every provider component and every project on the
|
|
38
|
+
// box, so the layer cache is hit; and the daemon dedupes identical
|
|
39
|
+
// dockerfile builds inside one bootstrap, so five provider services in one
|
|
40
|
+
// environment cost one build, not five.
|
|
41
|
+
//
|
|
42
|
+
// `entry.mjs` ships as an asset next to this file, read at load time and
|
|
43
|
+
// injected with `files`. It is not a `.ts` string constant: it imports
|
|
44
|
+
// `@emulators/*`, which the SDK does not depend on, so `tsc` could not
|
|
45
|
+
// check it anyway, and 200 lines inside a template literal is worse to
|
|
46
|
+
// maintain. `.mjs` also keeps it out of `tsconfig`'s `include` with no
|
|
47
|
+
// exclude rule, and `files: ["src"]` in the manifest still ships it.
|
|
48
|
+
import { readFileSync } from "node:fs";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
|
|
52
|
+
/** Base image, pinned. Bun runs the emulators' ESM directly. */
|
|
53
|
+
const IMAGE_BASE = "oven/bun:1.3.14-alpine";
|
|
54
|
+
/** Emulator package version, pinned rather than floated. */
|
|
55
|
+
const EMULATE_VERSION = "0.9.0";
|
|
56
|
+
/** The port the provider is served on. Callers reach it through the
|
|
57
|
+
* provider's real hostnames, never through this port. */
|
|
58
|
+
const PORT = 4000;
|
|
59
|
+
/** Ready-check budget. The container boots in about a second; the slow part
|
|
60
|
+
* is a cold image build on a machine that has never run it. */
|
|
61
|
+
const READY_TIMEOUT_SECS = 120;
|
|
62
|
+
/**
|
|
63
|
+
* The accounts every provider offers when a component declares none.
|
|
64
|
+
*
|
|
65
|
+
* Two, not one: a test that asserts it signed in as Alice only means
|
|
66
|
+
* something if the provider had somebody else to return. Both carry a
|
|
67
|
+
* `login`, so GitHub gets a handle without the caller supplying one.
|
|
68
|
+
*
|
|
69
|
+
* `example.com` is reserved for exactly this (RFC 2606), so no default
|
|
70
|
+
* account can ever collide with a real address.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_USERS = [
|
|
73
|
+
{ email: "alice@example.com", name: "Alice Example", login: "alice" },
|
|
74
|
+
{ email: "bob@example.com", name: "Bob Example", login: "bob" },
|
|
75
|
+
];
|
|
76
|
+
/** The entry script, read from disk next to this module. */
|
|
77
|
+
const ENTRY_SCRIPT = (() => {
|
|
78
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
79
|
+
return readFileSync(path.join(here, "entry.mjs"), "utf8");
|
|
80
|
+
})();
|
|
81
|
+
const DOCKERFILE = `
|
|
82
|
+
FROM ${IMAGE_BASE}
|
|
83
|
+
WORKDIR /srv
|
|
84
|
+
RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
|
|
85
|
+
&& bun add \\
|
|
86
|
+
@emulators/core@${EMULATE_VERSION} \\
|
|
87
|
+
@emulators/google@${EMULATE_VERSION} \\
|
|
88
|
+
@emulators/github@${EMULATE_VERSION} \\
|
|
89
|
+
@emulators/apple@${EMULATE_VERSION} \\
|
|
90
|
+
@emulators/microsoft@${EMULATE_VERSION} \\
|
|
91
|
+
@emulators/okta@${EMULATE_VERSION} \\
|
|
92
|
+
jose@6
|
|
93
|
+
`;
|
|
94
|
+
/** The service definition for one provider: the container, plus the claim
|
|
95
|
+
* on its domains (one certificate carrying every hostname as a SAN, and a
|
|
96
|
+
* proxy route each). One `certificate(...)` rather than per-host `tls`
|
|
97
|
+
* entries, for the reason `aws()` gives — `tls` mints a separate leaf per
|
|
98
|
+
* entry, and a provider can claim four names. */
|
|
99
|
+
export function emulatorService(spec) {
|
|
100
|
+
const service = {
|
|
101
|
+
image: { type: "dockerfile", content: DOCKERFILE },
|
|
102
|
+
command: "bun /srv/auth.mjs",
|
|
103
|
+
files: [{ path: "/srv/auth.mjs", content: ENTRY_SCRIPT }],
|
|
104
|
+
env: { SPECTEST_AUTH_CONFIG: JSON.stringify({ port: PORT, providers: [spec] }) },
|
|
105
|
+
ports: [PORT],
|
|
106
|
+
readyCheck: {
|
|
107
|
+
type: "http",
|
|
108
|
+
port: PORT,
|
|
109
|
+
path: "/healthz",
|
|
110
|
+
timeoutSecs: READY_TIMEOUT_SECS,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return provides(service, [
|
|
114
|
+
certificate(spec.hosts),
|
|
115
|
+
...spec.hosts.map((hostname) => proxy(hostname, { service: SELF_SERVICE_TOKEN, port: PORT })),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
// ── Seeding helpers, shared by the provider modules ───────────────────────
|
|
119
|
+
/** The accounts to seed: what the caller declared, or {@link DEFAULT_USERS}.
|
|
120
|
+
* An explicitly empty list is a mistake, not a request for none — a
|
|
121
|
+
* provider with no accounts can never sign anyone in. */
|
|
122
|
+
export function resolveUsers(component, users) {
|
|
123
|
+
if (users && users.length === 0) {
|
|
124
|
+
throw new Error(`${component}(): \`users\` is empty — omit it for the default accounts`);
|
|
125
|
+
}
|
|
126
|
+
return users ?? [...DEFAULT_USERS];
|
|
127
|
+
}
|
|
128
|
+
export function localPart(email) {
|
|
129
|
+
return email.split("@")[0] ?? email;
|
|
130
|
+
}
|
|
131
|
+
export function givenName(user) {
|
|
132
|
+
return user.name?.split(" ")[0];
|
|
133
|
+
}
|
|
134
|
+
export function familyName(user) {
|
|
135
|
+
const parts = user.name?.split(" ") ?? [];
|
|
136
|
+
return parts.length > 1 ? parts.slice(1).join(" ") : undefined;
|
|
137
|
+
}
|
|
138
|
+
/** The `oauth_clients` seed entry every provider but GitHub uses. */
|
|
139
|
+
export function oauthClients(client, extra = {}) {
|
|
140
|
+
if (!client)
|
|
141
|
+
return {};
|
|
142
|
+
return {
|
|
143
|
+
oauth_clients: [
|
|
144
|
+
{
|
|
145
|
+
client_id: client.clientId,
|
|
146
|
+
client_secret: client.clientSecret,
|
|
147
|
+
name: "spectest",
|
|
148
|
+
redirect_uris: client.redirectUris ?? [],
|
|
149
|
+
...extra,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|