@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `google()` — Google, emulated inside the environment, answering at its
|
|
2
|
+
// real endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Google: OpenID Connect discovery, the account
|
|
5
|
+
// picker, PKCE, the token exchange, refresh, and userinfo. The component is
|
|
6
|
+
// named for the provider rather than for the feature, because the backing
|
|
7
|
+
// emulator also carries Gmail, Calendar and Drive — claiming
|
|
8
|
+
// `gmail.googleapis.com` and routing it here is a table entry, not a new
|
|
9
|
+
// design.
|
|
10
|
+
|
|
11
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
12
|
+
import {
|
|
13
|
+
emulatorService,
|
|
14
|
+
familyName,
|
|
15
|
+
givenName,
|
|
16
|
+
oauthClients,
|
|
17
|
+
resolveUsers,
|
|
18
|
+
} from "./emulate/service.js";
|
|
19
|
+
|
|
20
|
+
export type GoogleOptions = ProviderOptions;
|
|
21
|
+
|
|
22
|
+
function spec(users: ProviderUser[], opts: GoogleOptions): ProviderSpec {
|
|
23
|
+
return {
|
|
24
|
+
name: "google",
|
|
25
|
+
module: "@emulators/google",
|
|
26
|
+
pluginExport: "googlePlugin",
|
|
27
|
+
baseUrl: "https://accounts.google.com",
|
|
28
|
+
hosts: [
|
|
29
|
+
"accounts.google.com",
|
|
30
|
+
"oauth2.googleapis.com",
|
|
31
|
+
"www.googleapis.com",
|
|
32
|
+
"openidconnect.googleapis.com",
|
|
33
|
+
],
|
|
34
|
+
discovery: {
|
|
35
|
+
issuer: "https://accounts.google.com",
|
|
36
|
+
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
37
|
+
token_endpoint: "https://oauth2.googleapis.com/token",
|
|
38
|
+
userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
39
|
+
revocation_endpoint: "https://oauth2.googleapis.com/revoke",
|
|
40
|
+
jwks_uri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
41
|
+
},
|
|
42
|
+
jwksPath: "^/oauth2/v3/certs$",
|
|
43
|
+
rewrites: [
|
|
44
|
+
// oauth2.googleapis.com serves these at the root; the emulator puts
|
|
45
|
+
// them under /oauth2.
|
|
46
|
+
{ from: "^/token$", to: "/oauth2/token" },
|
|
47
|
+
{ from: "^/revoke$", to: "/oauth2/revoke" },
|
|
48
|
+
// openidconnect.googleapis.com and the v3 alias both mean userinfo.
|
|
49
|
+
{ from: "^/v1/userinfo$", to: "/oauth2/v2/userinfo" },
|
|
50
|
+
{ from: "^/oauth2/v3/userinfo$", to: "/oauth2/v2/userinfo" },
|
|
51
|
+
],
|
|
52
|
+
fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "email", "profile"] },
|
|
53
|
+
seed: {
|
|
54
|
+
users: users.map((u) => ({
|
|
55
|
+
email: u.email,
|
|
56
|
+
name: u.name,
|
|
57
|
+
given_name: givenName(u),
|
|
58
|
+
family_name: familyName(u),
|
|
59
|
+
picture: u.picture,
|
|
60
|
+
email_verified: true,
|
|
61
|
+
})),
|
|
62
|
+
...oauthClients(opts.client),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Google, answering at its real endpoints. Drop into
|
|
69
|
+
* `environment.services`:
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* import { google } from "@specific.dev/spectest/components";
|
|
73
|
+
*
|
|
74
|
+
* services: {
|
|
75
|
+
* // Accounts default to Alice and Bob Example; declare `users` to
|
|
76
|
+
* // choose your own. A `client` makes the environment validate the
|
|
77
|
+
* // app's client id, secret and callback URL.
|
|
78
|
+
* google: google({
|
|
79
|
+
* client: {
|
|
80
|
+
* clientId: "1234.apps.googleusercontent.com",
|
|
81
|
+
* clientSecret: "GOCSPX-test",
|
|
82
|
+
* redirectUris: ["https://app.test/callback/google"],
|
|
83
|
+
* },
|
|
84
|
+
* }),
|
|
85
|
+
* app: { …, dependsOn: ["google"] },
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* The app keeps its production configuration — it sends a browser to
|
|
90
|
+
* `https://accounts.google.com/o/oauth2/v2/auth`, exchanges the code at
|
|
91
|
+
* `https://oauth2.googleapis.com/token`, and verifies the id_token against
|
|
92
|
+
* `https://www.googleapis.com/oauth2/v3/certs`, exactly as it does live.
|
|
93
|
+
*
|
|
94
|
+
* A test signs in the way a user does, through the browser:
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* await page.getByTestId("login-google").click();
|
|
98
|
+
* await page.getByTestId("spectest-user-alice@example.com").click();
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function google(opts: GoogleOptions = {}) {
|
|
102
|
+
const users = resolveUsers("google", opts.users);
|
|
103
|
+
return emulatorService(spec(users, opts));
|
|
104
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -62,6 +62,20 @@ export {
|
|
|
62
62
|
type AwsOptions,
|
|
63
63
|
type LambdaOptions,
|
|
64
64
|
} from "./aws.js";
|
|
65
|
+
// Third-party providers, each answering at its own real endpoints. Named
|
|
66
|
+
// for the provider rather than for sign-in, because each one's surface can
|
|
67
|
+
// grow past auth (Gmail and Calendar for `google`, the REST API for
|
|
68
|
+
// `github`, Graph for `microsoft`).
|
|
69
|
+
export {
|
|
70
|
+
type ProviderOptions,
|
|
71
|
+
type ProviderUser,
|
|
72
|
+
type OAuthClient,
|
|
73
|
+
} from "./emulate/service.js";
|
|
74
|
+
export { google, type GoogleOptions } from "./google.js";
|
|
75
|
+
export { github, type GithubOptions } from "./github.js";
|
|
76
|
+
export { apple, type AppleOptions } from "./apple.js";
|
|
77
|
+
export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
78
|
+
export { okta, type OktaOptions } from "./okta.js";
|
|
65
79
|
export {
|
|
66
80
|
replayFake,
|
|
67
81
|
type ReplayFakeOptions,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `microsoft()` — Microsoft, emulated inside the environment, answering at
|
|
2
|
+
// its real endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
|
|
5
|
+
// app reads straight after: discovery, the account picker, the token
|
|
6
|
+
// exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
|
|
7
|
+
// provider rather than for the feature — Graph is already claimed, and
|
|
8
|
+
// widening what it serves is a table entry.
|
|
9
|
+
|
|
10
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
11
|
+
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
12
|
+
|
|
13
|
+
export interface MicrosoftOptions extends ProviderOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Directory (tenant) the app signs in against — whatever its authority
|
|
16
|
+
* URL uses. It appears in the issuer and in every endpoint path, so a
|
|
17
|
+
* client that checks the issuer sees what it expects. Default `"common"`.
|
|
18
|
+
*/
|
|
19
|
+
tenantId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
|
|
23
|
+
const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
|
|
24
|
+
return {
|
|
25
|
+
name: "microsoft",
|
|
26
|
+
module: "@emulators/microsoft",
|
|
27
|
+
pluginExport: "microsoftPlugin",
|
|
28
|
+
baseUrl: "https://login.microsoftonline.com",
|
|
29
|
+
hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
|
|
30
|
+
// The emulator has no tenant, so its issuer is the bare host. A client
|
|
31
|
+
// that checks the issuer against the authority it configured would
|
|
32
|
+
// reject that; the re-issue step corrects it.
|
|
33
|
+
issuer: `${authority}/v2.0`,
|
|
34
|
+
discovery: {
|
|
35
|
+
issuer: `${authority}/v2.0`,
|
|
36
|
+
authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
|
|
37
|
+
token_endpoint: `${authority}/oauth2/v2.0/token`,
|
|
38
|
+
jwks_uri: `${authority}/discovery/v2.0/keys`,
|
|
39
|
+
end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
|
|
40
|
+
userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
|
|
41
|
+
},
|
|
42
|
+
jwksPath: "^/discovery/v2\\.0/keys$",
|
|
43
|
+
rewrites: [
|
|
44
|
+
// Real Entra paths carry the tenant; the emulator's do not. The
|
|
45
|
+
// discovery route is the exception — it handles the tenant itself.
|
|
46
|
+
{ from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
|
|
47
|
+
{ from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
|
|
48
|
+
],
|
|
49
|
+
fallbackUser: {
|
|
50
|
+
login: users[0]!.email,
|
|
51
|
+
id: 1,
|
|
52
|
+
scopes: ["openid", "email", "profile", "User.Read"],
|
|
53
|
+
},
|
|
54
|
+
seed: {
|
|
55
|
+
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
56
|
+
...oauthClients(opts.client),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Microsoft, answering at its real endpoints. Drop into
|
|
63
|
+
* `environment.services`:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { microsoft } from "@specific.dev/spectest/components";
|
|
67
|
+
*
|
|
68
|
+
* services: {
|
|
69
|
+
* microsoft: microsoft({
|
|
70
|
+
* tenantId: "11111111-2222-3333-4444-555555555555",
|
|
71
|
+
* users: [{ email: "bob@example.com", name: "Bob Example" }],
|
|
72
|
+
* client: {
|
|
73
|
+
* clientId: "6f1a2b3c-…",
|
|
74
|
+
* clientSecret: "secret",
|
|
75
|
+
* redirectUris: ["https://app.test/callback/microsoft"],
|
|
76
|
+
* },
|
|
77
|
+
* }),
|
|
78
|
+
* app: { …, dependsOn: ["microsoft"] },
|
|
79
|
+
* }
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* The app keeps its production configuration — discovery at
|
|
83
|
+
* `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
|
|
84
|
+
* and the account read back from `https://graph.microsoft.com/v1.0/me`.
|
|
85
|
+
*/
|
|
86
|
+
export function microsoft(opts: MicrosoftOptions = {}) {
|
|
87
|
+
const users = resolveUsers("microsoft", opts.users);
|
|
88
|
+
return emulatorService(spec(users, opts));
|
|
89
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `okta()` — Okta, emulated inside the environment, answering at your org's
|
|
2
|
+
// real domain.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Okta sign-in: discovery, the account picker, the token
|
|
5
|
+
// exchange, userinfo, introspection and revocation, on both the org
|
|
6
|
+
// authorization server (`/oauth2/v1/…`) and a custom one
|
|
7
|
+
// (`/oauth2/default/…`). The component is named for the provider rather
|
|
8
|
+
// than for the feature — the backing emulator also carries the users,
|
|
9
|
+
// groups and apps management API.
|
|
10
|
+
//
|
|
11
|
+
// Okta has no fixed hostname: every organization gets its own domain, so
|
|
12
|
+
// `domain` is required where the other providers need nothing.
|
|
13
|
+
|
|
14
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
15
|
+
import {
|
|
16
|
+
emulatorService,
|
|
17
|
+
familyName,
|
|
18
|
+
givenName,
|
|
19
|
+
oauthClients,
|
|
20
|
+
resolveUsers,
|
|
21
|
+
} from "./emulate/service.js";
|
|
22
|
+
|
|
23
|
+
export interface OktaOptions extends ProviderOptions {
|
|
24
|
+
/** The org's domain, e.g. `"dev-12345.okta.com"`. */
|
|
25
|
+
domain: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Okta's picker identifies an account by its opaque Okta id, so the id has
|
|
29
|
+
* to be seeded rather than generated — otherwise nothing on the page can be
|
|
30
|
+
* mapped back to an address. */
|
|
31
|
+
function oktaId(email: string): string {
|
|
32
|
+
return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function spec(users: ProviderUser[], opts: OktaOptions): ProviderSpec {
|
|
36
|
+
const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
37
|
+
return {
|
|
38
|
+
name: "okta",
|
|
39
|
+
module: "@emulators/okta",
|
|
40
|
+
pluginExport: "oktaPlugin",
|
|
41
|
+
baseUrl: `https://${domain}`,
|
|
42
|
+
hosts: [domain],
|
|
43
|
+
// Okta's issuer is per authorization server and the emulator already
|
|
44
|
+
// builds it from the base URL, which here is the real org domain.
|
|
45
|
+
jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
|
|
46
|
+
aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
|
|
47
|
+
fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "profile", "email"] },
|
|
48
|
+
seed: {
|
|
49
|
+
users: users.map((u) => ({
|
|
50
|
+
okta_id: oktaId(u.email),
|
|
51
|
+
// An Okta login is the address, not the GitHub-style handle that
|
|
52
|
+
// `login` carries — it comes back as `preferred_username`.
|
|
53
|
+
login: u.email,
|
|
54
|
+
email: u.email,
|
|
55
|
+
first_name: givenName(u),
|
|
56
|
+
last_name: familyName(u),
|
|
57
|
+
display_name: u.name,
|
|
58
|
+
})),
|
|
59
|
+
...oauthClients(opts.client),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Okta, answering at your org's real domain. Drop into
|
|
66
|
+
* `environment.services`:
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { okta } from "@specific.dev/spectest/components";
|
|
70
|
+
*
|
|
71
|
+
* services: {
|
|
72
|
+
* okta: okta({
|
|
73
|
+
* domain: "dev-12345.okta.com",
|
|
74
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
75
|
+
* client: {
|
|
76
|
+
* clientId: "0oaexample",
|
|
77
|
+
* clientSecret: "secret",
|
|
78
|
+
* redirectUris: ["https://app.test/callback/okta"],
|
|
79
|
+
* },
|
|
80
|
+
* }),
|
|
81
|
+
* app: { …, dependsOn: ["okta"] },
|
|
82
|
+
* }
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* The app keeps its production configuration — discovery at
|
|
86
|
+
* `https://dev-12345.okta.com/.well-known/openid-configuration`, and
|
|
87
|
+
* everything it names.
|
|
88
|
+
*/
|
|
89
|
+
export function okta(opts: OktaOptions) {
|
|
90
|
+
const users = resolveUsers("okta", opts.users);
|
|
91
|
+
if (!opts.domain) throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
|
|
92
|
+
return emulatorService(spec(users, opts));
|
|
93
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
proxy as makeProxyDecl,
|
|
35
35
|
} from "./index.js";
|
|
36
36
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
37
|
-
import { acquirePersistentBrowser } from "./browser.js";
|
|
37
|
+
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
38
38
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
39
39
|
// Pure ingress hostname matching, ported out of this file (see
|
|
40
40
|
// harness/hostmatch.ts). Keeping ONE implementation is the point: the
|
|
@@ -3191,6 +3191,13 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
3191
3191
|
/** Per-Browser rrweb session shipped to the control plane. */
|
|
3192
3192
|
interface BrowserSessionRecord {
|
|
3193
3193
|
sessionId: string;
|
|
3194
|
+
/** Author-given session name — `ctx.browser("alice")` / `ctx.mobile(app,
|
|
3195
|
+
* "alice")`. Absent for the default unnamed session, which is what makes
|
|
3196
|
+
* a single-browser case render with no naming chrome at all. The
|
|
3197
|
+
* dashboard labels this session's replay stage with it, and the step
|
|
3198
|
+
* list badges each op with the same name (off the event, not this
|
|
3199
|
+
* record). */
|
|
3200
|
+
name?: string;
|
|
3194
3201
|
openedAtMs: number;
|
|
3195
3202
|
closedAtMs?: number;
|
|
3196
3203
|
initialUrl?: string;
|
|
@@ -3277,6 +3284,23 @@ function newArtifactCollector(): {
|
|
|
3277
3284
|
};
|
|
3278
3285
|
}
|
|
3279
3286
|
|
|
3287
|
+
/**
|
|
3288
|
+
* Normalise `ctx.browser(...)`'s two call shapes — `(opts?)` for the
|
|
3289
|
+
* default session and `(name, opts?)` for a named one — into the
|
|
3290
|
+
* `(name, opts)` pair the session registry keys on. The overloads make
|
|
3291
|
+
* these the only two shapes that typecheck, but the implementation still
|
|
3292
|
+
* has to tell them apart at runtime: an untyped caller (`eval`, plain JS)
|
|
3293
|
+
* reaches the same function.
|
|
3294
|
+
*/
|
|
3295
|
+
function browserArgs(
|
|
3296
|
+
nameOrOpts?: string | BrowserOptions,
|
|
3297
|
+
opts?: BrowserOptions,
|
|
3298
|
+
): { name: string; opts: BrowserOptions | undefined } {
|
|
3299
|
+
return typeof nameOrOpts === "string"
|
|
3300
|
+
? { name: nameOrOpts, opts }
|
|
3301
|
+
: { name: "", opts: nameOrOpts };
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3280
3304
|
/**
|
|
3281
3305
|
* Build the recorder sink + bookkeeping for a single Browser session.
|
|
3282
3306
|
* The returned `recorder` is what `openBrowser` writes into; the
|
|
@@ -3290,6 +3314,7 @@ function newBrowserSession(
|
|
|
3290
3314
|
idScope: string,
|
|
3291
3315
|
frame: "browser" | "mobile" = "browser",
|
|
3292
3316
|
artifacts?: ReturnType<typeof newArtifactCollector>,
|
|
3317
|
+
name = "",
|
|
3293
3318
|
): {
|
|
3294
3319
|
recorder: BrowserSessionRecorder;
|
|
3295
3320
|
record: BrowserSessionRecord;
|
|
@@ -3299,6 +3324,10 @@ function newBrowserSession(
|
|
|
3299
3324
|
sessionId: newSessionId(idScope),
|
|
3300
3325
|
openedAtMs: Date.now() - testStart,
|
|
3301
3326
|
frame,
|
|
3327
|
+
// Omit the key entirely for the default session rather than storing
|
|
3328
|
+
// `""` — every consumer treats "no name" as "don't render a label",
|
|
3329
|
+
// and an empty string would have to be special-cased in each of them.
|
|
3330
|
+
...(name ? { name } : {}),
|
|
3302
3331
|
steps: [],
|
|
3303
3332
|
};
|
|
3304
3333
|
let closed = false;
|
|
@@ -3306,6 +3335,7 @@ function newBrowserSession(
|
|
|
3306
3335
|
record,
|
|
3307
3336
|
recorder: {
|
|
3308
3337
|
sessionId: record.sessionId,
|
|
3338
|
+
...(name ? { sessionName: name } : {}),
|
|
3309
3339
|
recordStep(step) {
|
|
3310
3340
|
if (closed) return;
|
|
3311
3341
|
record.steps.push(step);
|
|
@@ -4356,30 +4386,39 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
4356
4386
|
const parentId = testCase.dependsOn?.id;
|
|
4357
4387
|
const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
|
|
4358
4388
|
|
|
4359
|
-
// Browser/mobile sessions are PERSISTENT
|
|
4360
|
-
//
|
|
4361
|
-
// app
|
|
4362
|
-
// snapshot like fake state).
|
|
4363
|
-
//
|
|
4389
|
+
// Browser/mobile sessions are PERSISTENT and keyed by NAME:
|
|
4390
|
+
// `ctx.browser(name?)` acquires the desktop browser called `name` and
|
|
4391
|
+
// `ctx.mobile(app, name?)` that name's session for that app (browser.ts's
|
|
4392
|
+
// module-scoped registry, which forks with the snapshot like fake state).
|
|
4393
|
+
// The default name is `""`, so an unnamed project has exactly the one
|
|
4394
|
+
// session per device it always had. At test end we DETACH — final rrweb
|
|
4395
|
+
// drain, stop writing to this test's recorder — but deliberately keep the
|
|
4364
4396
|
// Chromium alive so the post-test snapshot captures it and dependsOn
|
|
4365
|
-
// children resume the live
|
|
4366
|
-
// state) instead of re-navigating
|
|
4367
|
-
//
|
|
4368
|
-
//
|
|
4369
|
-
//
|
|
4370
|
-
//
|
|
4371
|
-
//
|
|
4372
|
-
//
|
|
4373
|
-
//
|
|
4397
|
+
// children resume the live pages (cookies, localStorage, signed-in SPA
|
|
4398
|
+
// state) instead of re-navigating; that inheritance is per name, so a
|
|
4399
|
+
// parent that signed "alice" and "bob" in hands both down. Each test
|
|
4400
|
+
// still gets its own session record per name (attach re-arms rrweb with a
|
|
4401
|
+
// fresh full snapshot, so replays stay per-case self-contained); records
|
|
4402
|
+
// flow back to the control plane on RunResult.browserSessions and are
|
|
4403
|
+
// archived to S3 as the case's replay bundle. Within one test repeated
|
|
4404
|
+
// calls for the SAME name return the same handle (memoized below) so one
|
|
4405
|
+
// test = one session per (device, name). An explicit `.close()` destroys
|
|
4406
|
+
// that name's instance — its memo entry is cleared so a later call for
|
|
4407
|
+
// the same name starts fresh, and other names are untouched.
|
|
4374
4408
|
const browserDetaches: Array<() => Promise<void>> = [];
|
|
4375
4409
|
const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
|
|
4376
|
-
|
|
4410
|
+
const sharedBrowsers = new Map<string, Browser>();
|
|
4377
4411
|
const sharedMobiles = new Map<string, Mobile>();
|
|
4378
|
-
const trackedOpenBrowser = async (
|
|
4379
|
-
|
|
4380
|
-
|
|
4412
|
+
const trackedOpenBrowser = async (
|
|
4413
|
+
nameOrOpts?: string | BrowserOptions,
|
|
4414
|
+
maybeOpts?: BrowserOptions,
|
|
4415
|
+
): Promise<Browser> => {
|
|
4416
|
+
const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
|
|
4417
|
+
const memo = sharedBrowsers.get(name);
|
|
4418
|
+
if (memo) return memo;
|
|
4419
|
+
const session = newBrowserSession(start, testCase.id, "browser", undefined, name);
|
|
4381
4420
|
sessions.push(session);
|
|
4382
|
-
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4421
|
+
const { browser, attached, detach } = await acquirePersistentBrowser(name, {
|
|
4383
4422
|
...(opts ?? {}),
|
|
4384
4423
|
recorder: session.recorder,
|
|
4385
4424
|
});
|
|
@@ -4398,23 +4437,25 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
4398
4437
|
const innerClose = browser.close.bind(browser);
|
|
4399
4438
|
browser.close = async () => {
|
|
4400
4439
|
await innerClose();
|
|
4401
|
-
if (
|
|
4440
|
+
if (sharedBrowsers.get(name) === browser) sharedBrowsers.delete(name);
|
|
4402
4441
|
};
|
|
4403
|
-
|
|
4442
|
+
sharedBrowsers.set(name, browser);
|
|
4404
4443
|
return browser;
|
|
4405
4444
|
};
|
|
4406
|
-
const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
|
|
4445
|
+
const trackedOpenMobile = async (app: MobileApp, name = ""): Promise<Mobile> => {
|
|
4407
4446
|
if (!isMobileApp(app)) {
|
|
4408
4447
|
throw new Error(
|
|
4409
4448
|
"ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
|
|
4410
4449
|
);
|
|
4411
4450
|
}
|
|
4412
|
-
const
|
|
4451
|
+
const key = mobileKey(name, app.url);
|
|
4452
|
+
const existing = sharedMobiles.get(key);
|
|
4413
4453
|
if (existing) return existing;
|
|
4414
|
-
const session = newBrowserSession(start, testCase.id, "mobile");
|
|
4454
|
+
const session = newBrowserSession(start, testCase.id, "mobile", undefined, name);
|
|
4415
4455
|
sessions.push(session);
|
|
4416
4456
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4417
4457
|
url: app.url,
|
|
4458
|
+
name,
|
|
4418
4459
|
recorder: session.recorder,
|
|
4419
4460
|
initScript: app.initScript,
|
|
4420
4461
|
});
|
|
@@ -4429,9 +4470,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
4429
4470
|
const innerClose = mobile.close.bind(mobile);
|
|
4430
4471
|
mobile.close = async () => {
|
|
4431
4472
|
await innerClose();
|
|
4432
|
-
if (sharedMobiles.get(
|
|
4473
|
+
if (sharedMobiles.get(key) === mobile) sharedMobiles.delete(key);
|
|
4433
4474
|
};
|
|
4434
|
-
sharedMobiles.set(
|
|
4475
|
+
sharedMobiles.set(key, mobile);
|
|
4435
4476
|
return mobile;
|
|
4436
4477
|
};
|
|
4437
4478
|
|
|
@@ -4840,13 +4881,18 @@ async function evalCode(
|
|
|
4840
4881
|
// Eval-only artifact sink — wiring it here (and nowhere in runOne) is
|
|
4841
4882
|
// what gates screenshot() to eval context.
|
|
4842
4883
|
const artifactCollector = newArtifactCollector();
|
|
4843
|
-
|
|
4884
|
+
const sharedBrowsers = new Map<string, Browser>();
|
|
4844
4885
|
const sharedMobiles = new Map<string, Mobile>();
|
|
4845
|
-
const trackedOpenBrowser = async (
|
|
4846
|
-
|
|
4847
|
-
|
|
4886
|
+
const trackedOpenBrowser = async (
|
|
4887
|
+
nameOrOpts?: string | BrowserOptions,
|
|
4888
|
+
maybeOpts?: BrowserOptions,
|
|
4889
|
+
): Promise<Browser> => {
|
|
4890
|
+
const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
|
|
4891
|
+
const memo = sharedBrowsers.get(name);
|
|
4892
|
+
if (memo) return memo;
|
|
4893
|
+
const session = newBrowserSession(start, "eval", "browser", artifactCollector, name);
|
|
4848
4894
|
sessions.push(session);
|
|
4849
|
-
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4895
|
+
const { browser, attached, detach } = await acquirePersistentBrowser(name, {
|
|
4850
4896
|
...(opts ?? {}),
|
|
4851
4897
|
recorder: session.recorder,
|
|
4852
4898
|
});
|
|
@@ -4863,23 +4909,25 @@ async function evalCode(
|
|
|
4863
4909
|
const innerClose = browser.close.bind(browser);
|
|
4864
4910
|
browser.close = async () => {
|
|
4865
4911
|
await innerClose();
|
|
4866
|
-
if (
|
|
4912
|
+
if (sharedBrowsers.get(name) === browser) sharedBrowsers.delete(name);
|
|
4867
4913
|
};
|
|
4868
|
-
|
|
4914
|
+
sharedBrowsers.set(name, browser);
|
|
4869
4915
|
return browser;
|
|
4870
4916
|
};
|
|
4871
|
-
const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
|
|
4917
|
+
const trackedOpenMobile = async (app: MobileApp, name = ""): Promise<Mobile> => {
|
|
4872
4918
|
if (!isMobileApp(app)) {
|
|
4873
4919
|
throw new Error(
|
|
4874
4920
|
"ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
|
|
4875
4921
|
);
|
|
4876
4922
|
}
|
|
4877
|
-
const
|
|
4923
|
+
const key = mobileKey(name, app.url);
|
|
4924
|
+
const existing = sharedMobiles.get(key);
|
|
4878
4925
|
if (existing) return existing;
|
|
4879
|
-
const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
|
|
4926
|
+
const session = newBrowserSession(start, "eval", "mobile", artifactCollector, name);
|
|
4880
4927
|
sessions.push(session);
|
|
4881
4928
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4882
4929
|
url: app.url,
|
|
4930
|
+
name,
|
|
4883
4931
|
recorder: session.recorder,
|
|
4884
4932
|
initScript: app.initScript,
|
|
4885
4933
|
});
|
|
@@ -4894,9 +4942,9 @@ async function evalCode(
|
|
|
4894
4942
|
const innerClose = mobile.close.bind(mobile);
|
|
4895
4943
|
mobile.close = async () => {
|
|
4896
4944
|
await innerClose();
|
|
4897
|
-
if (sharedMobiles.get(
|
|
4945
|
+
if (sharedMobiles.get(key) === mobile) sharedMobiles.delete(key);
|
|
4898
4946
|
};
|
|
4899
|
-
sharedMobiles.set(
|
|
4947
|
+
sharedMobiles.set(key, mobile);
|
|
4900
4948
|
return mobile;
|
|
4901
4949
|
};
|
|
4902
4950
|
|
package/src/index.ts
CHANGED
|
@@ -1405,20 +1405,57 @@ export interface TestContext<
|
|
|
1405
1405
|
/**
|
|
1406
1406
|
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1407
1407
|
*
|
|
1408
|
-
* There is
|
|
1409
|
-
* call returns it, and it stays alive across
|
|
1410
|
-
* of the state a test's snapshot captures, so
|
|
1411
|
-
* the exact live page its parent left
|
|
1412
|
-
* SPA state). Sign in once in a parent
|
|
1413
|
-
* signed in. Sibling tests fork from
|
|
1414
|
-
* can't see each other's browsing. A
|
|
1415
|
-
* gets a fresh browser on first call
|
|
1408
|
+
* There is one persistent browser PER NAME, and `ctx.browser()` is the
|
|
1409
|
+
* default (unnamed) one: every call returns it, and it stays alive across
|
|
1410
|
+
* tests — the browser is part of the state a test's snapshot captures, so
|
|
1411
|
+
* a `dependsOn` child resumes the exact live page its parent left
|
|
1412
|
+
* (cookies, localStorage, signed-in SPA state). Sign in once in a parent
|
|
1413
|
+
* test; every descendant is already signed in. Sibling tests fork from
|
|
1414
|
+
* the same parent snapshot, so they can't see each other's browsing. A
|
|
1415
|
+
* test with no browser-using ancestor gets a fresh browser on first call
|
|
1416
|
+
* (first call's options win).
|
|
1417
|
+
*
|
|
1418
|
+
* For a second, independent browser — a second user — name it:
|
|
1419
|
+
* `ctx.browser("alice")`. See the named overload.
|
|
1416
1420
|
*
|
|
1417
1421
|
* `.close()` destroys the shared instance — the next `ctx.browser()`
|
|
1418
1422
|
* starts fresh. Don't call it for routine cleanup; recording is detached
|
|
1419
1423
|
* automatically at test end.
|
|
1420
1424
|
*/
|
|
1421
1425
|
browser(opts?: BrowserOptions): Promise<Browser>;
|
|
1426
|
+
/**
|
|
1427
|
+
* Open the persistent browser called `name`, creating it on first use.
|
|
1428
|
+
* Each name is its own browser — its own cookies, localStorage and page —
|
|
1429
|
+
* so naming them is how one test drives two users.
|
|
1430
|
+
*
|
|
1431
|
+
* **Only name a browser when the test needs two or more isolated sessions
|
|
1432
|
+
* at once.** Otherwise use `ctx.browser()`: a name is a second identity,
|
|
1433
|
+
* not a label, and a lone `ctx.browser("main")` buys nothing over the
|
|
1434
|
+
* default while adding its name to every step title in the dashboard.
|
|
1435
|
+
*
|
|
1436
|
+
* ```ts
|
|
1437
|
+
* const alice = await ctx.browser("alice", { url: "https://app.test" });
|
|
1438
|
+
* const bob = await ctx.browser("bob", { url: "https://app.test" });
|
|
1439
|
+
* await alice.getByRole("button", { name: "Share" }).click();
|
|
1440
|
+
* await bob.reload();
|
|
1441
|
+
* await expect(bob.getByText("Shared with you")).toBeVisible();
|
|
1442
|
+
* ```
|
|
1443
|
+
*
|
|
1444
|
+
* Every rule of the default browser applies per name: the session rides
|
|
1445
|
+
* the snapshot, so a `dependsOn` child inherits *each* named browser
|
|
1446
|
+
* exactly where its parent left it (sign both users in once, in the
|
|
1447
|
+
* parent); repeat calls with the same name in one test return the same
|
|
1448
|
+
* handle; the first call for a name wins its options; `.close()` discards
|
|
1449
|
+
* only that name. Sessions are named for the roles under test, not per
|
|
1450
|
+
* row of data — each one is a live browser captured in every snapshot
|
|
1451
|
+
* from here on, and opening too many is an error.
|
|
1452
|
+
*
|
|
1453
|
+
* The dashboard titles each step with the browser that performed it
|
|
1454
|
+
* (`bob: click "Share"`) and labels the replay with the same name, so a
|
|
1455
|
+
* two-user timeline stays readable and selecting a step shows that
|
|
1456
|
+
* browser's session.
|
|
1457
|
+
*/
|
|
1458
|
+
browser(name: string, opts?: BrowserOptions): Promise<Browser>;
|
|
1422
1459
|
/**
|
|
1423
1460
|
* Open a phone-emulated session for a mobile app and return a {@link Mobile}
|
|
1424
1461
|
* handle already pointed at it — no `navigate`. Pass the app handle a
|
|
@@ -1437,13 +1474,18 @@ export interface TestContext<
|
|
|
1437
1474
|
* The session emulates the latest iPhone (viewport + DPR + mobile UA +
|
|
1438
1475
|
* touch) and the dashboard replays it inside a phone bezel.
|
|
1439
1476
|
*
|
|
1440
|
-
* Sessions are persistent, one per app
|
|
1441
|
-
* session is captured in the test's snapshot, so a `dependsOn`
|
|
1442
|
-
* picks up the app exactly where the parent left it (already signed
|
|
1443
|
-
* mid-flow) instead of reloading it. `.close()` discards the session;
|
|
1477
|
+
* Sessions are persistent, one per app per `name`: like `ctx.browser()`,
|
|
1478
|
+
* the live session is captured in the test's snapshot, so a `dependsOn`
|
|
1479
|
+
* child picks up the app exactly where the parent left it (already signed
|
|
1480
|
+
* in, mid-flow) instead of reloading it. `.close()` discards the session;
|
|
1444
1481
|
* the next `ctx.mobile(app)` opens the app fresh.
|
|
1482
|
+
*
|
|
1483
|
+
* Pass a `name` for a second phone running the same app — two users in
|
|
1484
|
+
* one test: `ctx.mobile(ctx.svc.app, "alice")`. Names are independent
|
|
1485
|
+
* sessions and are inherited per name by `dependsOn` children, exactly
|
|
1486
|
+
* like named desktop browsers.
|
|
1445
1487
|
*/
|
|
1446
|
-
mobile(app: MobileApp): Promise<Mobile>;
|
|
1488
|
+
mobile(app: MobileApp, name?: string): Promise<Mobile>;
|
|
1447
1489
|
/** The test's display name. */
|
|
1448
1490
|
readonly testName: string;
|
|
1449
1491
|
/**
|