@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/daemon.js CHANGED
@@ -22,7 +22,7 @@ import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
25
- import { acquirePersistentBrowser } from "./browser.js";
25
+ import { acquirePersistentBrowser, mobileKey } from "./browser.js";
26
26
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
27
27
  // Pure ingress hostname matching, ported out of this file (see
28
28
  // harness/hostmatch.ts). Keeping ONE implementation is the point: the
@@ -2686,6 +2686,19 @@ function newArtifactCollector() {
2686
2686
  },
2687
2687
  };
2688
2688
  }
2689
+ /**
2690
+ * Normalise `ctx.browser(...)`'s two call shapes — `(opts?)` for the
2691
+ * default session and `(name, opts?)` for a named one — into the
2692
+ * `(name, opts)` pair the session registry keys on. The overloads make
2693
+ * these the only two shapes that typecheck, but the implementation still
2694
+ * has to tell them apart at runtime: an untyped caller (`eval`, plain JS)
2695
+ * reaches the same function.
2696
+ */
2697
+ function browserArgs(nameOrOpts, opts) {
2698
+ return typeof nameOrOpts === "string"
2699
+ ? { name: nameOrOpts, opts }
2700
+ : { name: "", opts: nameOrOpts };
2701
+ }
2689
2702
  /**
2690
2703
  * Build the recorder sink + bookkeeping for a single Browser session.
2691
2704
  * The returned `recorder` is what `openBrowser` writes into; the
@@ -2694,11 +2707,15 @@ function newArtifactCollector() {
2694
2707
  * test-run sessions don't pass it, which is exactly what makes
2695
2708
  * `screenshot()` throw outside eval.
2696
2709
  */
2697
- function newBrowserSession(testStart, idScope, frame = "browser", artifacts) {
2710
+ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, name = "") {
2698
2711
  const record = {
2699
2712
  sessionId: newSessionId(idScope),
2700
2713
  openedAtMs: Date.now() - testStart,
2701
2714
  frame,
2715
+ // Omit the key entirely for the default session rather than storing
2716
+ // `""` — every consumer treats "no name" as "don't render a label",
2717
+ // and an empty string would have to be special-cased in each of them.
2718
+ ...(name ? { name } : {}),
2702
2719
  steps: [],
2703
2720
  };
2704
2721
  let closed = false;
@@ -2706,6 +2723,7 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts) {
2706
2723
  record,
2707
2724
  recorder: {
2708
2725
  sessionId: record.sessionId,
2726
+ ...(name ? { sessionName: name } : {}),
2709
2727
  recordStep(step) {
2710
2728
  if (closed)
2711
2729
  return;
@@ -3537,31 +3555,37 @@ async function runOne(testCase) {
3537
3555
  // an ancestor fork; its TEST_DATA entry travels with the snapshot.
3538
3556
  const parentId = testCase.dependsOn?.id;
3539
3557
  const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
3540
- // Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
3541
- // shared desktop browser and `ctx.mobile(app)` the one session for that
3542
- // app (browser.ts's module-scoped registry, which forks with the
3543
- // snapshot like fake state). At test end we DETACH — final rrweb drain,
3544
- // stop writing to this test's recorder — but deliberately keep the
3558
+ // Browser/mobile sessions are PERSISTENT and keyed by NAME:
3559
+ // `ctx.browser(name?)` acquires the desktop browser called `name` and
3560
+ // `ctx.mobile(app, name?)` that name's session for that app (browser.ts's
3561
+ // module-scoped registry, which forks with the snapshot like fake state).
3562
+ // The default name is `""`, so an unnamed project has exactly the one
3563
+ // session per device it always had. At test end we DETACH — final rrweb
3564
+ // drain, stop writing to this test's recorder — but deliberately keep the
3545
3565
  // Chromium alive so the post-test snapshot captures it and dependsOn
3546
- // children resume the live page (cookies, localStorage, signed-in SPA
3547
- // state) instead of re-navigating. Each test still gets its own session
3548
- // record (attach re-arms rrweb with a fresh full snapshot, so replays
3549
- // stay per-case self-contained); records flow back to the control plane
3550
- // on RunResult.browserSessions and are archived to S3 as the case's
3551
- // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
3552
- // calls return the same handle (memoized below) so one test = one
3553
- // session per device. An explicit `.close()` destroys the shared
3554
- // instance — the memo is cleared so a later call starts fresh.
3566
+ // children resume the live pages (cookies, localStorage, signed-in SPA
3567
+ // state) instead of re-navigating; that inheritance is per name, so a
3568
+ // parent that signed "alice" and "bob" in hands both down. Each test
3569
+ // still gets its own session record per name (attach re-arms rrweb with a
3570
+ // fresh full snapshot, so replays stay per-case self-contained); records
3571
+ // flow back to the control plane on RunResult.browserSessions and are
3572
+ // archived to S3 as the case's replay bundle. Within one test repeated
3573
+ // calls for the SAME name return the same handle (memoized below) so one
3574
+ // test = one session per (device, name). An explicit `.close()` destroys
3575
+ // that name's instance — its memo entry is cleared so a later call for
3576
+ // the same name starts fresh, and other names are untouched.
3555
3577
  const browserDetaches = [];
3556
3578
  const sessions = [];
3557
- let sharedBrowser = null;
3579
+ const sharedBrowsers = new Map();
3558
3580
  const sharedMobiles = new Map();
3559
- const trackedOpenBrowser = async (opts) => {
3560
- if (sharedBrowser)
3561
- return sharedBrowser;
3562
- const session = newBrowserSession(start, testCase.id);
3581
+ const trackedOpenBrowser = async (nameOrOpts, maybeOpts) => {
3582
+ const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
3583
+ const memo = sharedBrowsers.get(name);
3584
+ if (memo)
3585
+ return memo;
3586
+ const session = newBrowserSession(start, testCase.id, "browser", undefined, name);
3563
3587
  sessions.push(session);
3564
- const { browser, attached, detach } = await acquirePersistentBrowser({
3588
+ const { browser, attached, detach } = await acquirePersistentBrowser(name, {
3565
3589
  ...(opts ?? {}),
3566
3590
  recorder: session.recorder,
3567
3591
  });
@@ -3580,23 +3604,25 @@ async function runOne(testCase) {
3580
3604
  const innerClose = browser.close.bind(browser);
3581
3605
  browser.close = async () => {
3582
3606
  await innerClose();
3583
- if (sharedBrowser === browser)
3584
- sharedBrowser = null;
3607
+ if (sharedBrowsers.get(name) === browser)
3608
+ sharedBrowsers.delete(name);
3585
3609
  };
3586
- sharedBrowser = browser;
3610
+ sharedBrowsers.set(name, browser);
3587
3611
  return browser;
3588
3612
  };
3589
- const trackedOpenMobile = async (app) => {
3613
+ const trackedOpenMobile = async (app, name = "") => {
3590
3614
  if (!isMobileApp(app)) {
3591
3615
  throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
3592
3616
  }
3593
- const existing = sharedMobiles.get(app.url);
3617
+ const key = mobileKey(name, app.url);
3618
+ const existing = sharedMobiles.get(key);
3594
3619
  if (existing)
3595
3620
  return existing;
3596
- const session = newBrowserSession(start, testCase.id, "mobile");
3621
+ const session = newBrowserSession(start, testCase.id, "mobile", undefined, name);
3597
3622
  sessions.push(session);
3598
3623
  const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
3599
3624
  url: app.url,
3625
+ name,
3600
3626
  recorder: session.recorder,
3601
3627
  initScript: app.initScript,
3602
3628
  });
@@ -3612,10 +3638,10 @@ async function runOne(testCase) {
3612
3638
  const innerClose = mobile.close.bind(mobile);
3613
3639
  mobile.close = async () => {
3614
3640
  await innerClose();
3615
- if (sharedMobiles.get(app.url) === mobile)
3616
- sharedMobiles.delete(app.url);
3641
+ if (sharedMobiles.get(key) === mobile)
3642
+ sharedMobiles.delete(key);
3617
3643
  };
3618
- sharedMobiles.set(app.url, mobile);
3644
+ sharedMobiles.set(key, mobile);
3619
3645
  return mobile;
3620
3646
  };
3621
3647
  // The shared context (svc/fakes handles, poll, project files, dnsName,
@@ -3975,14 +4001,16 @@ async function evalCode(code, secrets) {
3975
4001
  // Eval-only artifact sink — wiring it here (and nowhere in runOne) is
3976
4002
  // what gates screenshot() to eval context.
3977
4003
  const artifactCollector = newArtifactCollector();
3978
- let sharedBrowser = null;
4004
+ const sharedBrowsers = new Map();
3979
4005
  const sharedMobiles = new Map();
3980
- const trackedOpenBrowser = async (opts) => {
3981
- if (sharedBrowser)
3982
- return sharedBrowser;
3983
- const session = newBrowserSession(start, "eval", "browser", artifactCollector);
4006
+ const trackedOpenBrowser = async (nameOrOpts, maybeOpts) => {
4007
+ const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
4008
+ const memo = sharedBrowsers.get(name);
4009
+ if (memo)
4010
+ return memo;
4011
+ const session = newBrowserSession(start, "eval", "browser", artifactCollector, name);
3984
4012
  sessions.push(session);
3985
- const { browser, attached, detach } = await acquirePersistentBrowser({
4013
+ const { browser, attached, detach } = await acquirePersistentBrowser(name, {
3986
4014
  ...(opts ?? {}),
3987
4015
  recorder: session.recorder,
3988
4016
  });
@@ -3999,23 +4027,25 @@ async function evalCode(code, secrets) {
3999
4027
  const innerClose = browser.close.bind(browser);
4000
4028
  browser.close = async () => {
4001
4029
  await innerClose();
4002
- if (sharedBrowser === browser)
4003
- sharedBrowser = null;
4030
+ if (sharedBrowsers.get(name) === browser)
4031
+ sharedBrowsers.delete(name);
4004
4032
  };
4005
- sharedBrowser = browser;
4033
+ sharedBrowsers.set(name, browser);
4006
4034
  return browser;
4007
4035
  };
4008
- const trackedOpenMobile = async (app) => {
4036
+ const trackedOpenMobile = async (app, name = "") => {
4009
4037
  if (!isMobileApp(app)) {
4010
4038
  throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
4011
4039
  }
4012
- const existing = sharedMobiles.get(app.url);
4040
+ const key = mobileKey(name, app.url);
4041
+ const existing = sharedMobiles.get(key);
4013
4042
  if (existing)
4014
4043
  return existing;
4015
- const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
4044
+ const session = newBrowserSession(start, "eval", "mobile", artifactCollector, name);
4016
4045
  sessions.push(session);
4017
4046
  const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
4018
4047
  url: app.url,
4048
+ name,
4019
4049
  recorder: session.recorder,
4020
4050
  initScript: app.initScript,
4021
4051
  });
@@ -4031,10 +4061,10 @@ async function evalCode(code, secrets) {
4031
4061
  const innerClose = mobile.close.bind(mobile);
4032
4062
  mobile.close = async () => {
4033
4063
  await innerClose();
4034
- if (sharedMobiles.get(app.url) === mobile)
4035
- sharedMobiles.delete(app.url);
4064
+ if (sharedMobiles.get(key) === mobile)
4065
+ sharedMobiles.delete(key);
4036
4066
  };
4037
- sharedMobiles.set(app.url, mobile);
4067
+ sharedMobiles.set(key, mobile);
4038
4068
  return mobile;
4039
4069
  };
4040
4070
  // Terminal sessions — same shape as runOne, but eval has no active
package/dist/index.d.ts CHANGED
@@ -936,20 +936,57 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
936
936
  /**
937
937
  * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
938
938
  *
939
- * There is ONE persistent browser per environment: every `ctx.browser()`
940
- * call returns it, and it stays alive across tests — the browser is part
941
- * of the state a test's snapshot captures, so a `dependsOn` child resumes
942
- * the exact live page its parent left (cookies, localStorage, signed-in
943
- * SPA state). Sign in once in a parent test; every descendant is already
944
- * signed in. Sibling tests fork from the same parent snapshot, so they
945
- * can't see each other's browsing. A test with no browser-using ancestor
946
- * gets a fresh browser on first call (first call's options win).
939
+ * There is one persistent browser PER NAME, and `ctx.browser()` is the
940
+ * default (unnamed) one: every call returns it, and it stays alive across
941
+ * tests — the browser is part of the state a test's snapshot captures, so
942
+ * a `dependsOn` child resumes the exact live page its parent left
943
+ * (cookies, localStorage, signed-in SPA state). Sign in once in a parent
944
+ * test; every descendant is already signed in. Sibling tests fork from
945
+ * the same parent snapshot, so they can't see each other's browsing. A
946
+ * test with no browser-using ancestor gets a fresh browser on first call
947
+ * (first call's options win).
948
+ *
949
+ * For a second, independent browser — a second user — name it:
950
+ * `ctx.browser("alice")`. See the named overload.
947
951
  *
948
952
  * `.close()` destroys the shared instance — the next `ctx.browser()`
949
953
  * starts fresh. Don't call it for routine cleanup; recording is detached
950
954
  * automatically at test end.
951
955
  */
952
956
  browser(opts?: BrowserOptions): Promise<Browser>;
957
+ /**
958
+ * Open the persistent browser called `name`, creating it on first use.
959
+ * Each name is its own browser — its own cookies, localStorage and page —
960
+ * so naming them is how one test drives two users.
961
+ *
962
+ * **Only name a browser when the test needs two or more isolated sessions
963
+ * at once.** Otherwise use `ctx.browser()`: a name is a second identity,
964
+ * not a label, and a lone `ctx.browser("main")` buys nothing over the
965
+ * default while adding its name to every step title in the dashboard.
966
+ *
967
+ * ```ts
968
+ * const alice = await ctx.browser("alice", { url: "https://app.test" });
969
+ * const bob = await ctx.browser("bob", { url: "https://app.test" });
970
+ * await alice.getByRole("button", { name: "Share" }).click();
971
+ * await bob.reload();
972
+ * await expect(bob.getByText("Shared with you")).toBeVisible();
973
+ * ```
974
+ *
975
+ * Every rule of the default browser applies per name: the session rides
976
+ * the snapshot, so a `dependsOn` child inherits *each* named browser
977
+ * exactly where its parent left it (sign both users in once, in the
978
+ * parent); repeat calls with the same name in one test return the same
979
+ * handle; the first call for a name wins its options; `.close()` discards
980
+ * only that name. Sessions are named for the roles under test, not per
981
+ * row of data — each one is a live browser captured in every snapshot
982
+ * from here on, and opening too many is an error.
983
+ *
984
+ * The dashboard titles each step with the browser that performed it
985
+ * (`bob: click "Share"`) and labels the replay with the same name, so a
986
+ * two-user timeline stays readable and selecting a step shows that
987
+ * browser's session.
988
+ */
989
+ browser(name: string, opts?: BrowserOptions): Promise<Browser>;
953
990
  /**
954
991
  * Open a phone-emulated session for a mobile app and return a {@link Mobile}
955
992
  * handle already pointed at it — no `navigate`. Pass the app handle a
@@ -968,13 +1005,18 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
968
1005
  * The session emulates the latest iPhone (viewport + DPR + mobile UA +
969
1006
  * touch) and the dashboard replays it inside a phone bezel.
970
1007
  *
971
- * Sessions are persistent, one per app: like `ctx.browser()`, the live
972
- * session is captured in the test's snapshot, so a `dependsOn` child
973
- * picks up the app exactly where the parent left it (already signed in,
974
- * mid-flow) instead of reloading it. `.close()` discards the session;
1008
+ * Sessions are persistent, one per app per `name`: like `ctx.browser()`,
1009
+ * the live session is captured in the test's snapshot, so a `dependsOn`
1010
+ * child picks up the app exactly where the parent left it (already signed
1011
+ * in, mid-flow) instead of reloading it. `.close()` discards the session;
975
1012
  * the next `ctx.mobile(app)` opens the app fresh.
1013
+ *
1014
+ * Pass a `name` for a second phone running the same app — two users in
1015
+ * one test: `ctx.mobile(ctx.svc.app, "alice")`. Names are independent
1016
+ * sessions and are inherited per name by `dependsOn` children, exactly
1017
+ * like named desktop browsers.
976
1018
  */
977
- mobile(app: MobileApp): Promise<Mobile>;
1019
+ mobile(app: MobileApp, name?: string): Promise<Mobile>;
978
1020
  /** The test's display name. */
979
1021
  readonly testName: string;
980
1022
  /**
package/dist/mobile.d.ts CHANGED
@@ -49,14 +49,18 @@ export declare function openMobile(opts: {
49
49
  recorder: BrowserSessionRecorder | null;
50
50
  }): Promise<Mobile>;
51
51
  /**
52
- * Acquire the persistent phone-emulated session for an app (one per app URL,
53
- * created on first use). The daemon calls this from `ctx.mobile(app)` with a
54
- * per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
55
- * the dashboard renders a phone bezel. `detach` is the test-end hook;
56
- * `mobile.close()` destroys the session for real.
52
+ * Acquire the persistent phone-emulated session for an app (one per app URL
53
+ * per `name`, created on first use). The daemon calls this from
54
+ * `ctx.mobile(app, name?)` with a per-test rrweb recorder; the resulting
55
+ * record carries `frame: "mobile"` so the dashboard renders a phone bezel.
56
+ * `detach` is the test-end hook; `mobile.close()` destroys the session for
57
+ * real.
57
58
  */
58
59
  export declare function openPersistentMobile(opts: {
59
60
  url: string;
61
+ /** Session name — `""` (the default) is the unnamed session every
62
+ * `ctx.mobile(app)` call shares. Two names on one app are two phones. */
63
+ name?: string;
60
64
  recorder: BrowserSessionRecorder | null;
61
65
  /** Installed before the fresh session's first navigation (ignored on an
62
66
  * attached session, which already carries it on the forked holder). */
package/dist/mobile.js CHANGED
@@ -48,14 +48,15 @@ export async function openMobile(opts) {
48
48
  return backend;
49
49
  }
50
50
  /**
51
- * Acquire the persistent phone-emulated session for an app (one per app URL,
52
- * created on first use). The daemon calls this from `ctx.mobile(app)` with a
53
- * per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
54
- * the dashboard renders a phone bezel. `detach` is the test-end hook;
55
- * `mobile.close()` destroys the session for real.
51
+ * Acquire the persistent phone-emulated session for an app (one per app URL
52
+ * per `name`, created on first use). The daemon calls this from
53
+ * `ctx.mobile(app, name?)` with a per-test rrweb recorder; the resulting
54
+ * record carries `frame: "mobile"` so the dashboard renders a phone bezel.
55
+ * `detach` is the test-end hook; `mobile.close()` destroys the session for
56
+ * real.
56
57
  */
57
58
  export async function openPersistentMobile(opts) {
58
- const { browser, attached, detach } = await acquirePersistentMobileBackend(opts.url, opts.recorder, opts.initScript);
59
+ const { browser, attached, detach } = await acquirePersistentMobileBackend(opts.url, opts.name ?? "", opts.recorder, opts.initScript);
59
60
  return {
60
61
  mobile: browser,
61
62
  attached,
@@ -379,6 +379,16 @@ export interface BrowserEvent extends BaseEvent {
379
379
  * with a `BrowserSessionRecorder` attached (the daemon always does).
380
380
  */
381
381
  sessionId?: string;
382
+ /**
383
+ * Author-given name of that session — `ctx.browser("alice")` — absent
384
+ * for the default unnamed browser, so a single-browser test's steps
385
+ * carry no name and render exactly as they always have. Present, it is
386
+ * what the step list badges to say which browser acted. Duplicated here
387
+ * rather than resolved from the session record because the CLI's
388
+ * failure detail renders from persisted events alone, with no replay
389
+ * bundle to look the session up in.
390
+ */
391
+ sessionName?: string;
382
392
  /**
383
393
  * Wall-clock `Date.now()` captured at the moment this op *finished*.
384
394
  * Lives in the same time base as rrweb's `event.timestamp` fields,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.41.0",
3
+ "version": "0.44.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -136,6 +136,14 @@ export interface BrowserSessionRecorder {
136
136
  * the dashboard can link a browser event to its replay player.
137
137
  */
138
138
  readonly sessionId: string;
139
+ /**
140
+ * Author-given name of this session (`ctx.browser("alice")`), absent
141
+ * for the default unnamed one. Echoed onto every op's event so the
142
+ * step list can say WHICH browser acted without having to resolve the
143
+ * session record — which the CLI's failure detail can't do (it renders
144
+ * from the persisted events alone, with no replay bundle).
145
+ */
146
+ readonly sessionName?: string;
139
147
  /** Called for each drained chunk of rrweb events. */
140
148
  recordStep(step: BrowserSessionStep): void;
141
149
  /** Optional: called whenever `Browser.goto(url)` is invoked. */
@@ -1304,17 +1312,50 @@ export async function openMobileBackend(
1304
1312
  // Persistent sessions (the default behind ctx.browser / ctx.mobile)
1305
1313
  // ────────────────────────────────────────────────────────────────────────
1306
1314
 
1307
- // One long-lived desktop browser plus one mobile session per app URL.
1315
+ // Long-lived sessions, keyed by NAME: one desktop browser per name, one
1316
+ // mobile session per (name, app URL). The default name is `""` — what
1317
+ // `ctx.browser()` / `ctx.mobile(app)` use — so an unnamed project keeps
1318
+ // exactly the one-session-per-device behaviour it had before names
1319
+ // existed. A name is the key and nothing else: two names are two
1320
+ // BrowserContexts in the one Chromium, i.e. two independent cookie jars
1321
+ // and localStorage, which is what modelling two users needs.
1322
+ //
1308
1323
  // Module state lives in daemon memory, so it forks with the snapshot the
1309
- // same way fake `state` and TEST_DATA do: a test's browser — its live
1310
- // page, cookies, localStorage, in-memory SPA state — is captured in the
1324
+ // same way fake `state` and TEST_DATA do: a test's browsers — their live
1325
+ // pages, cookies, localStorage, in-memory SPA state — are captured in the
1311
1326
  // post-test snapshot and inherited by `dependsOn` children, while sibling
1312
1327
  // forks never see each other's sessions. That's what lets a child test
1313
1328
  // continue where its parent left off (e.g. already signed in) instead of
1314
- // re-navigating and re-authenticating.
1315
- let SHARED_BROWSER: ViewHolder | null = null;
1329
+ // re-navigating and re-authenticating. The name is a plain string, so it
1330
+ // keys the same session on both sides of a fork.
1331
+ const SHARED_BROWSERS = new Map<string, ViewHolder>();
1316
1332
  const SHARED_MOBILE = new Map<string, ViewHolder>();
1317
1333
 
1334
+ /**
1335
+ * Ceiling on live persistent sessions of one kind (desktop / mobile).
1336
+ * Every session is a Chromium BrowserContext that rides every snapshot
1337
+ * from here down the DAG, so a test that mints names in a loop
1338
+ * (`ctx.browser(userId)`) would grow the VM's memory floor for the rest
1339
+ * of the run. Failing loudly at a sane count beats a wedged guest.
1340
+ */
1341
+ const MAX_PERSISTENT_SESSIONS = 8;
1342
+
1343
+ /** Registry key for a named mobile session. NUL can't occur in a name or
1344
+ * a URL, so the two halves can never run together ambiguously. */
1345
+ export function mobileKey(name: string, url: string): string {
1346
+ return `${name}\u0000${url}`;
1347
+ }
1348
+
1349
+ /** Guard the session cap, naming the offender and what to do about it. */
1350
+ function checkSessionCap(kind: string, live: number, name: string): void {
1351
+ if (live < MAX_PERSISTENT_SESSIONS) return;
1352
+ throw new Error(
1353
+ `too many ${kind} sessions: ${MAX_PERSISTENT_SESSIONS} are already open and "${name}" would be another. ` +
1354
+ `Each named session is a live browser captured in every snapshot from here on — name them for the ` +
1355
+ `roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`,
1356
+ );
1357
+ }
1358
+
1318
1359
  async function newHolder(
1319
1360
  width: number,
1320
1361
  height: number,
@@ -1395,53 +1436,61 @@ async function attachReset(holder: ViewHolder): Promise<void> {
1395
1436
  }
1396
1437
 
1397
1438
  /**
1398
- * Acquire THE persistent desktop browser (creating it on first use). There
1399
- * is deliberately a single one — `ctx.browser()` always returns it — so a
1400
- * test DAG shares one browsing session along each branch. The first call's
1401
- * options win; later calls attach to the existing view as-is.
1439
+ * Acquire the persistent desktop browser called `name` (creating it on
1440
+ * first use). There is one per name — `ctx.browser()` uses the default
1441
+ * name `""` — so a test DAG shares each named browsing session along each
1442
+ * branch, and two names are two independent users. The first call for a
1443
+ * name wins its options; later calls attach to the existing view as-is.
1402
1444
  */
1403
1445
  export async function acquirePersistentBrowser(
1446
+ name = "",
1404
1447
  opts: BrowserOptions = {},
1405
1448
  ): Promise<PersistentBrowser> {
1406
1449
  const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
1407
- let attached = true;
1408
- if (!SHARED_BROWSER) {
1409
- attached = false;
1410
- SHARED_BROWSER = await newHolder(
1450
+ let holder = SHARED_BROWSERS.get(name);
1451
+ const attached = holder !== undefined;
1452
+ if (holder) {
1453
+ await attachReset(holder);
1454
+ } else {
1455
+ checkSessionCap("browser", SHARED_BROWSERS.size, name);
1456
+ holder = await newHolder(
1411
1457
  device ? device.viewport.width : opts.width ?? 1280,
1412
1458
  device ? device.viewport.height : opts.height ?? 720,
1413
1459
  device,
1414
1460
  );
1415
- } else {
1416
- await attachReset(SHARED_BROWSER);
1461
+ SHARED_BROWSERS.set(name, holder);
1417
1462
  }
1418
- const holder = SHARED_BROWSER;
1419
- const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
1463
+ const view = holder;
1464
+ const { backend, detach } = buildBackend(view, opts.recorder ?? null, {
1420
1465
  persistent: true,
1421
1466
  onDestroy: () => {
1422
- if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
1467
+ if (SHARED_BROWSERS.get(name) === view) SHARED_BROWSERS.delete(name);
1423
1468
  },
1424
1469
  });
1425
1470
  // Fresh session only: an attached view already carries the init script on
1426
1471
  // its (forked) holder, and first-call-wins means a later call's options
1427
1472
  // don't retroactively apply.
1428
1473
  if (!attached && opts.initScript !== undefined) {
1429
- await installInitScript(holder, opts.initScript);
1474
+ await installInitScript(view, opts.initScript);
1430
1475
  }
1431
1476
  if (!attached && opts.url !== undefined) await backend.goto(opts.url);
1432
1477
  return { browser: backend, attached, detach };
1433
1478
  }
1434
1479
 
1435
1480
  /**
1436
- * Acquire the persistent mobile session for an app URL (one per app). A
1437
- * fresh session navigates to the app; an attach continues on the live page.
1481
+ * Acquire the persistent mobile session for an app URL under `name` (one
1482
+ * per name per app; `ctx.mobile(app)` uses the default name `""`). A fresh
1483
+ * session navigates to the app; an attach continues on the live page.
1438
1484
  */
1439
1485
  export async function acquirePersistentMobileBackend(
1440
1486
  url: string,
1487
+ name: string,
1441
1488
  recorder: BrowserSessionRecorder | null,
1442
1489
  initScript?: string,
1443
1490
  ): Promise<PersistentBrowser> {
1444
- const existing = SHARED_MOBILE.get(url);
1491
+ const key = mobileKey(name, url);
1492
+ const existing = SHARED_MOBILE.get(key);
1493
+ if (!existing) checkSessionCap("mobile", SHARED_MOBILE.size, name);
1445
1494
  const holder =
1446
1495
  existing ??
1447
1496
  (await newHolder(
@@ -1452,12 +1501,12 @@ export async function acquirePersistentMobileBackend(
1452
1501
  if (existing) {
1453
1502
  await attachReset(holder);
1454
1503
  } else {
1455
- SHARED_MOBILE.set(url, holder);
1504
+ SHARED_MOBILE.set(key, holder);
1456
1505
  }
1457
1506
  const { backend, detach } = buildBackend(holder, recorder, {
1458
1507
  persistent: true,
1459
1508
  onDestroy: () => {
1460
- if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
1509
+ if (SHARED_MOBILE.get(key) === holder) SHARED_MOBILE.delete(key);
1461
1510
  },
1462
1511
  });
1463
1512
  if (!existing) {
@@ -1549,6 +1598,18 @@ function buildBackend(
1549
1598
  // missing from the chunk we're about to drain (see `drainExpr`).
1550
1599
  let lastDrainUrl: string | null = null;
1551
1600
 
1601
+ /** Session provenance stamped on every recorded op: which replay player
1602
+ * the step belongs to, which named browser it acted on, and where in
1603
+ * the player to seek (`endT`, in rrweb's clock). */
1604
+ function sessionFields(endT: number): Record<string, unknown> {
1605
+ if (!recorder) return {};
1606
+ return {
1607
+ sessionId: recorder.sessionId,
1608
+ sessionTimestamp: endT,
1609
+ ...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
1610
+ };
1611
+ }
1612
+
1552
1613
  async function drain(action: BrowserAction | "close"): Promise<void> {
1553
1614
  if (!holder.recordingInstalled || !recorder || recordingEnded) return;
1554
1615
  try {
@@ -1595,9 +1656,7 @@ function buildBackend(
1595
1656
  const seq = recordBrowser({
1596
1657
  action,
1597
1658
  ...fields,
1598
- ...(recorder
1599
- ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1600
- : {}),
1659
+ ...sessionFields(endT),
1601
1660
  durationMs: endT - t,
1602
1661
  }, resv);
1603
1662
  await drain(action);
@@ -1614,9 +1673,7 @@ function buildBackend(
1614
1673
  recordBrowser({
1615
1674
  action,
1616
1675
  ...fields,
1617
- ...(recorder
1618
- ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1619
- : {}),
1676
+ ...sessionFields(endT),
1620
1677
  durationMs: endT - t,
1621
1678
  error: e?.message ?? String(err),
1622
1679
  }, resv);
@@ -1988,9 +2045,7 @@ function buildBackend(
1988
2045
  const seq = recordBrowser({
1989
2046
  action,
1990
2047
  ...fields,
1991
- ...(recorder
1992
- ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1993
- : {}),
2048
+ ...sessionFields(endT),
1994
2049
  durationMs: waitedMs,
1995
2050
  ...(error ? { error } : {}),
1996
2051
  }, reserveBackdated(waitedMs));