@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.
@@ -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
+ }
@@ -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: `ctx.browser()` acquires THE
4360
- // shared desktop browser and `ctx.mobile(app)` the one session for that
4361
- // app (browser.ts's module-scoped registry, which forks with the
4362
- // snapshot like fake state). At test end we DETACH — final rrweb drain,
4363
- // stop writing to this test's recorder — but deliberately keep the
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 page (cookies, localStorage, signed-in SPA
4366
- // state) instead of re-navigating. Each test still gets its own session
4367
- // record (attach re-arms rrweb with a fresh full snapshot, so replays
4368
- // stay per-case self-contained); records flow back to the control plane
4369
- // on RunResult.browserSessions and are archived to S3 as the case's
4370
- // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
4371
- // calls return the same handle (memoized below) so one test = one
4372
- // session per device. An explicit `.close()` destroys the shared
4373
- // instance — the memo is cleared so a later call starts fresh.
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
- let sharedBrowser: Browser | null = null;
4410
+ const sharedBrowsers = new Map<string, Browser>();
4377
4411
  const sharedMobiles = new Map<string, Mobile>();
4378
- const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
4379
- if (sharedBrowser) return sharedBrowser;
4380
- const session = newBrowserSession(start, testCase.id);
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 (sharedBrowser === browser) sharedBrowser = null;
4440
+ if (sharedBrowsers.get(name) === browser) sharedBrowsers.delete(name);
4402
4441
  };
4403
- sharedBrowser = browser;
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 existing = sharedMobiles.get(app.url);
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(app.url) === mobile) sharedMobiles.delete(app.url);
4473
+ if (sharedMobiles.get(key) === mobile) sharedMobiles.delete(key);
4433
4474
  };
4434
- sharedMobiles.set(app.url, mobile);
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
- let sharedBrowser: Browser | null = null;
4884
+ const sharedBrowsers = new Map<string, Browser>();
4844
4885
  const sharedMobiles = new Map<string, Mobile>();
4845
- const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
4846
- if (sharedBrowser) return sharedBrowser;
4847
- const session = newBrowserSession(start, "eval", "browser", artifactCollector);
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 (sharedBrowser === browser) sharedBrowser = null;
4912
+ if (sharedBrowsers.get(name) === browser) sharedBrowsers.delete(name);
4867
4913
  };
4868
- sharedBrowser = browser;
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 existing = sharedMobiles.get(app.url);
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(app.url) === mobile) sharedMobiles.delete(app.url);
4945
+ if (sharedMobiles.get(key) === mobile) sharedMobiles.delete(key);
4898
4946
  };
4899
- sharedMobiles.set(app.url, mobile);
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 ONE persistent browser per environment: every `ctx.browser()`
1409
- * call returns it, and it stays alive across tests — the browser is part
1410
- * of the state a test's snapshot captures, so a `dependsOn` child resumes
1411
- * the exact live page its parent left (cookies, localStorage, signed-in
1412
- * SPA state). Sign in once in a parent test; every descendant is already
1413
- * signed in. Sibling tests fork from the same parent snapshot, so they
1414
- * can't see each other's browsing. A test with no browser-using ancestor
1415
- * gets a fresh browser on first call (first call's options win).
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: like `ctx.browser()`, the live
1441
- * session is captured in the test's snapshot, so a `dependsOn` child
1442
- * picks up the app exactly where the parent left it (already signed in,
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
  /**