@specific.dev/spectest 0.41.0 → 0.43.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/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/daemon.ts +87 -39
- package/src/index.ts +55 -13
- package/src/mobile.ts +10 -5
- package/src/recorder.ts +10 -0
package/dist/browser.d.ts
CHANGED
|
@@ -60,6 +60,14 @@ export interface BrowserSessionRecorder {
|
|
|
60
60
|
* the dashboard can link a browser event to its replay player.
|
|
61
61
|
*/
|
|
62
62
|
readonly sessionId: string;
|
|
63
|
+
/**
|
|
64
|
+
* Author-given name of this session (`ctx.browser("alice")`), absent
|
|
65
|
+
* for the default unnamed one. Echoed onto every op's event so the
|
|
66
|
+
* step list can say WHICH browser acted without having to resolve the
|
|
67
|
+
* session record — which the CLI's failure detail can't do (it renders
|
|
68
|
+
* from the persisted events alone, with no replay bundle).
|
|
69
|
+
*/
|
|
70
|
+
readonly sessionName?: string;
|
|
63
71
|
/** Called for each drained chunk of rrweb events. */
|
|
64
72
|
recordStep(step: BrowserSessionStep): void;
|
|
65
73
|
/** Optional: called whenever `Browser.goto(url)` is invoked. */
|
|
@@ -302,6 +310,9 @@ export declare function openBrowser(opts?: BrowserOptions): Promise<Browser>;
|
|
|
302
310
|
* the same object.
|
|
303
311
|
*/
|
|
304
312
|
export declare function openMobileBackend(opts?: BrowserOptions): Promise<MobileBackend>;
|
|
313
|
+
/** Registry key for a named mobile session. NUL can't occur in a name or
|
|
314
|
+
* a URL, so the two halves can never run together ambiguously. */
|
|
315
|
+
export declare function mobileKey(name: string, url: string): string;
|
|
305
316
|
/**
|
|
306
317
|
* What acquiring a persistent session returns. `detach` is the test-end
|
|
307
318
|
* hook (final rrweb drain, stop writing to this test's recorder, keep the
|
|
@@ -318,17 +329,19 @@ export interface PersistentBrowser {
|
|
|
318
329
|
detach(): Promise<void>;
|
|
319
330
|
}
|
|
320
331
|
/**
|
|
321
|
-
* Acquire
|
|
322
|
-
*
|
|
323
|
-
* test DAG shares
|
|
324
|
-
*
|
|
332
|
+
* Acquire the persistent desktop browser called `name` (creating it on
|
|
333
|
+
* first use). There is one per name — `ctx.browser()` uses the default
|
|
334
|
+
* name `""` — so a test DAG shares each named browsing session along each
|
|
335
|
+
* branch, and two names are two independent users. The first call for a
|
|
336
|
+
* name wins its options; later calls attach to the existing view as-is.
|
|
325
337
|
*/
|
|
326
|
-
export declare function acquirePersistentBrowser(opts?: BrowserOptions): Promise<PersistentBrowser>;
|
|
338
|
+
export declare function acquirePersistentBrowser(name?: string, opts?: BrowserOptions): Promise<PersistentBrowser>;
|
|
327
339
|
/**
|
|
328
|
-
* Acquire the persistent mobile session for an app URL (one
|
|
329
|
-
*
|
|
340
|
+
* Acquire the persistent mobile session for an app URL under `name` (one
|
|
341
|
+
* per name per app; `ctx.mobile(app)` uses the default name `""`). A fresh
|
|
342
|
+
* session navigates to the app; an attach continues on the live page.
|
|
330
343
|
*/
|
|
331
|
-
export declare function acquirePersistentMobileBackend(url: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
|
|
344
|
+
export declare function acquirePersistentMobileBackend(url: string, name: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
|
|
332
345
|
export interface RecordableFields {
|
|
333
346
|
url: string;
|
|
334
347
|
selector: string;
|
package/dist/browser.js
CHANGED
|
@@ -872,16 +872,45 @@ export async function openMobileBackend(opts = {}) {
|
|
|
872
872
|
// ────────────────────────────────────────────────────────────────────────
|
|
873
873
|
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
874
874
|
// ────────────────────────────────────────────────────────────────────────
|
|
875
|
-
//
|
|
875
|
+
// Long-lived sessions, keyed by NAME: one desktop browser per name, one
|
|
876
|
+
// mobile session per (name, app URL). The default name is `""` — what
|
|
877
|
+
// `ctx.browser()` / `ctx.mobile(app)` use — so an unnamed project keeps
|
|
878
|
+
// exactly the one-session-per-device behaviour it had before names
|
|
879
|
+
// existed. A name is the key and nothing else: two names are two
|
|
880
|
+
// BrowserContexts in the one Chromium, i.e. two independent cookie jars
|
|
881
|
+
// and localStorage, which is what modelling two users needs.
|
|
882
|
+
//
|
|
876
883
|
// Module state lives in daemon memory, so it forks with the snapshot the
|
|
877
|
-
// same way fake `state` and TEST_DATA do: a test's
|
|
878
|
-
//
|
|
884
|
+
// same way fake `state` and TEST_DATA do: a test's browsers — their live
|
|
885
|
+
// pages, cookies, localStorage, in-memory SPA state — are captured in the
|
|
879
886
|
// post-test snapshot and inherited by `dependsOn` children, while sibling
|
|
880
887
|
// forks never see each other's sessions. That's what lets a child test
|
|
881
888
|
// continue where its parent left off (e.g. already signed in) instead of
|
|
882
|
-
// re-navigating and re-authenticating.
|
|
883
|
-
|
|
889
|
+
// re-navigating and re-authenticating. The name is a plain string, so it
|
|
890
|
+
// keys the same session on both sides of a fork.
|
|
891
|
+
const SHARED_BROWSERS = new Map();
|
|
884
892
|
const SHARED_MOBILE = new Map();
|
|
893
|
+
/**
|
|
894
|
+
* Ceiling on live persistent sessions of one kind (desktop / mobile).
|
|
895
|
+
* Every session is a Chromium BrowserContext that rides every snapshot
|
|
896
|
+
* from here down the DAG, so a test that mints names in a loop
|
|
897
|
+
* (`ctx.browser(userId)`) would grow the VM's memory floor for the rest
|
|
898
|
+
* of the run. Failing loudly at a sane count beats a wedged guest.
|
|
899
|
+
*/
|
|
900
|
+
const MAX_PERSISTENT_SESSIONS = 8;
|
|
901
|
+
/** Registry key for a named mobile session. NUL can't occur in a name or
|
|
902
|
+
* a URL, so the two halves can never run together ambiguously. */
|
|
903
|
+
export function mobileKey(name, url) {
|
|
904
|
+
return `${name}\u0000${url}`;
|
|
905
|
+
}
|
|
906
|
+
/** Guard the session cap, naming the offender and what to do about it. */
|
|
907
|
+
function checkSessionCap(kind, live, name) {
|
|
908
|
+
if (live < MAX_PERSISTENT_SESSIONS)
|
|
909
|
+
return;
|
|
910
|
+
throw new Error(`too many ${kind} sessions: ${MAX_PERSISTENT_SESSIONS} are already open and "${name}" would be another. ` +
|
|
911
|
+
`Each named session is a live browser captured in every snapshot from here on — name them for the ` +
|
|
912
|
+
`roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
|
|
913
|
+
}
|
|
885
914
|
async function newHolder(width, height, device) {
|
|
886
915
|
const context = await newViewContext(width, height, device);
|
|
887
916
|
const spawned = await spawnPage(context);
|
|
@@ -941,58 +970,65 @@ async function attachReset(holder) {
|
|
|
941
970
|
}
|
|
942
971
|
}
|
|
943
972
|
/**
|
|
944
|
-
* Acquire
|
|
945
|
-
*
|
|
946
|
-
* test DAG shares
|
|
947
|
-
*
|
|
973
|
+
* Acquire the persistent desktop browser called `name` (creating it on
|
|
974
|
+
* first use). There is one per name — `ctx.browser()` uses the default
|
|
975
|
+
* name `""` — so a test DAG shares each named browsing session along each
|
|
976
|
+
* branch, and two names are two independent users. The first call for a
|
|
977
|
+
* name wins its options; later calls attach to the existing view as-is.
|
|
948
978
|
*/
|
|
949
|
-
export async function acquirePersistentBrowser(opts = {}) {
|
|
979
|
+
export async function acquirePersistentBrowser(name = "", opts = {}) {
|
|
950
980
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
951
|
-
let
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
981
|
+
let holder = SHARED_BROWSERS.get(name);
|
|
982
|
+
const attached = holder !== undefined;
|
|
983
|
+
if (holder) {
|
|
984
|
+
await attachReset(holder);
|
|
955
985
|
}
|
|
956
986
|
else {
|
|
957
|
-
|
|
987
|
+
checkSessionCap("browser", SHARED_BROWSERS.size, name);
|
|
988
|
+
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device);
|
|
989
|
+
SHARED_BROWSERS.set(name, holder);
|
|
958
990
|
}
|
|
959
|
-
const
|
|
960
|
-
const { backend, detach } = buildBackend(
|
|
991
|
+
const view = holder;
|
|
992
|
+
const { backend, detach } = buildBackend(view, opts.recorder ?? null, {
|
|
961
993
|
persistent: true,
|
|
962
994
|
onDestroy: () => {
|
|
963
|
-
if (
|
|
964
|
-
|
|
995
|
+
if (SHARED_BROWSERS.get(name) === view)
|
|
996
|
+
SHARED_BROWSERS.delete(name);
|
|
965
997
|
},
|
|
966
998
|
});
|
|
967
999
|
// Fresh session only: an attached view already carries the init script on
|
|
968
1000
|
// its (forked) holder, and first-call-wins means a later call's options
|
|
969
1001
|
// don't retroactively apply.
|
|
970
1002
|
if (!attached && opts.initScript !== undefined) {
|
|
971
|
-
await installInitScript(
|
|
1003
|
+
await installInitScript(view, opts.initScript);
|
|
972
1004
|
}
|
|
973
1005
|
if (!attached && opts.url !== undefined)
|
|
974
1006
|
await backend.goto(opts.url);
|
|
975
1007
|
return { browser: backend, attached, detach };
|
|
976
1008
|
}
|
|
977
1009
|
/**
|
|
978
|
-
* Acquire the persistent mobile session for an app URL (one
|
|
979
|
-
*
|
|
1010
|
+
* Acquire the persistent mobile session for an app URL under `name` (one
|
|
1011
|
+
* per name per app; `ctx.mobile(app)` uses the default name `""`). A fresh
|
|
1012
|
+
* session navigates to the app; an attach continues on the live page.
|
|
980
1013
|
*/
|
|
981
|
-
export async function acquirePersistentMobileBackend(url, recorder, initScript) {
|
|
982
|
-
const
|
|
1014
|
+
export async function acquirePersistentMobileBackend(url, name, recorder, initScript) {
|
|
1015
|
+
const key = mobileKey(name, url);
|
|
1016
|
+
const existing = SHARED_MOBILE.get(key);
|
|
1017
|
+
if (!existing)
|
|
1018
|
+
checkSessionCap("mobile", SHARED_MOBILE.size, name);
|
|
983
1019
|
const holder = existing ??
|
|
984
1020
|
(await newHolder(LATEST_IPHONE.viewport.width, LATEST_IPHONE.viewport.height, LATEST_IPHONE));
|
|
985
1021
|
if (existing) {
|
|
986
1022
|
await attachReset(holder);
|
|
987
1023
|
}
|
|
988
1024
|
else {
|
|
989
|
-
SHARED_MOBILE.set(
|
|
1025
|
+
SHARED_MOBILE.set(key, holder);
|
|
990
1026
|
}
|
|
991
1027
|
const { backend, detach } = buildBackend(holder, recorder, {
|
|
992
1028
|
persistent: true,
|
|
993
1029
|
onDestroy: () => {
|
|
994
|
-
if (SHARED_MOBILE.get(
|
|
995
|
-
SHARED_MOBILE.delete(
|
|
1030
|
+
if (SHARED_MOBILE.get(key) === holder)
|
|
1031
|
+
SHARED_MOBILE.delete(key);
|
|
996
1032
|
},
|
|
997
1033
|
});
|
|
998
1034
|
if (!existing) {
|
|
@@ -1069,6 +1105,18 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1069
1105
|
// when rrweb's load-deferred full snapshot is most likely to be
|
|
1070
1106
|
// missing from the chunk we're about to drain (see `drainExpr`).
|
|
1071
1107
|
let lastDrainUrl = null;
|
|
1108
|
+
/** Session provenance stamped on every recorded op: which replay player
|
|
1109
|
+
* the step belongs to, which named browser it acted on, and where in
|
|
1110
|
+
* the player to seek (`endT`, in rrweb's clock). */
|
|
1111
|
+
function sessionFields(endT) {
|
|
1112
|
+
if (!recorder)
|
|
1113
|
+
return {};
|
|
1114
|
+
return {
|
|
1115
|
+
sessionId: recorder.sessionId,
|
|
1116
|
+
sessionTimestamp: endT,
|
|
1117
|
+
...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1072
1120
|
async function drain(action) {
|
|
1073
1121
|
if (!holder.recordingInstalled || !recorder || recordingEnded)
|
|
1074
1122
|
return;
|
|
@@ -1112,9 +1160,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1112
1160
|
const seq = recordBrowser({
|
|
1113
1161
|
action,
|
|
1114
1162
|
...fields,
|
|
1115
|
-
...(
|
|
1116
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1117
|
-
: {}),
|
|
1163
|
+
...sessionFields(endT),
|
|
1118
1164
|
durationMs: endT - t,
|
|
1119
1165
|
}, resv);
|
|
1120
1166
|
await drain(action);
|
|
@@ -1132,9 +1178,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1132
1178
|
recordBrowser({
|
|
1133
1179
|
action,
|
|
1134
1180
|
...fields,
|
|
1135
|
-
...(
|
|
1136
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1137
|
-
: {}),
|
|
1181
|
+
...sessionFields(endT),
|
|
1138
1182
|
durationMs: endT - t,
|
|
1139
1183
|
error: e?.message ?? String(err),
|
|
1140
1184
|
}, resv);
|
|
@@ -1459,9 +1503,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1459
1503
|
const seq = recordBrowser({
|
|
1460
1504
|
action,
|
|
1461
1505
|
...fields,
|
|
1462
|
-
...(
|
|
1463
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1464
|
-
: {}),
|
|
1506
|
+
...sessionFields(endT),
|
|
1465
1507
|
durationMs: waitedMs,
|
|
1466
1508
|
...(error ? { error } : {}),
|
|
1467
1509
|
}, reserveBackdated(waitedMs));
|
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
|
|
3541
|
-
//
|
|
3542
|
-
// app
|
|
3543
|
-
// snapshot like fake state).
|
|
3544
|
-
//
|
|
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
|
|
3547
|
-
// state) instead of re-navigating
|
|
3548
|
-
//
|
|
3549
|
-
//
|
|
3550
|
-
//
|
|
3551
|
-
//
|
|
3552
|
-
//
|
|
3553
|
-
//
|
|
3554
|
-
//
|
|
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
|
-
|
|
3579
|
+
const sharedBrowsers = new Map();
|
|
3558
3580
|
const sharedMobiles = new Map();
|
|
3559
|
-
const trackedOpenBrowser = async (
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
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 (
|
|
3584
|
-
|
|
3607
|
+
if (sharedBrowsers.get(name) === browser)
|
|
3608
|
+
sharedBrowsers.delete(name);
|
|
3585
3609
|
};
|
|
3586
|
-
|
|
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
|
|
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(
|
|
3616
|
-
sharedMobiles.delete(
|
|
3641
|
+
if (sharedMobiles.get(key) === mobile)
|
|
3642
|
+
sharedMobiles.delete(key);
|
|
3617
3643
|
};
|
|
3618
|
-
sharedMobiles.set(
|
|
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
|
-
|
|
4004
|
+
const sharedBrowsers = new Map();
|
|
3979
4005
|
const sharedMobiles = new Map();
|
|
3980
|
-
const trackedOpenBrowser = async (
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
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 (
|
|
4003
|
-
|
|
4030
|
+
if (sharedBrowsers.get(name) === browser)
|
|
4031
|
+
sharedBrowsers.delete(name);
|
|
4004
4032
|
};
|
|
4005
|
-
|
|
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
|
|
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(
|
|
4035
|
-
sharedMobiles.delete(
|
|
4064
|
+
if (sharedMobiles.get(key) === mobile)
|
|
4065
|
+
sharedMobiles.delete(key);
|
|
4036
4066
|
};
|
|
4037
|
-
sharedMobiles.set(
|
|
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
|
|
940
|
-
* call returns it, and it stays alive across
|
|
941
|
-
* of the state a test's snapshot captures, so
|
|
942
|
-
* the exact live page its parent left
|
|
943
|
-
* SPA state). Sign in once in a parent
|
|
944
|
-
* signed in. Sibling tests fork from
|
|
945
|
-
* can't see each other's browsing. A
|
|
946
|
-
* gets a fresh browser on first call
|
|
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
|
|
972
|
-
* session is captured in the test's snapshot, so a `dependsOn`
|
|
973
|
-
* picks up the app exactly where the parent left it (already signed
|
|
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
|
|
54
|
-
* per-test rrweb recorder; the resulting
|
|
55
|
-
* the dashboard renders a phone bezel.
|
|
56
|
-
* `mobile.close()` destroys the session for
|
|
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
|
|
53
|
-
* per-test rrweb recorder; the resulting
|
|
54
|
-
* the dashboard renders a phone bezel.
|
|
55
|
-
* `mobile.close()` destroys the session for
|
|
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,
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
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
|
-
//
|
|
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
|
|
1310
|
-
//
|
|
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
|
-
|
|
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
|
|
1399
|
-
*
|
|
1400
|
-
* test DAG shares
|
|
1401
|
-
*
|
|
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
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1416
|
-
await attachReset(SHARED_BROWSER);
|
|
1461
|
+
SHARED_BROWSERS.set(name, holder);
|
|
1417
1462
|
}
|
|
1418
|
-
const
|
|
1419
|
-
const { backend, detach } = buildBackend(
|
|
1463
|
+
const view = holder;
|
|
1464
|
+
const { backend, detach } = buildBackend(view, opts.recorder ?? null, {
|
|
1420
1465
|
persistent: true,
|
|
1421
1466
|
onDestroy: () => {
|
|
1422
|
-
if (
|
|
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(
|
|
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
|
|
1437
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
1992
|
-
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
1993
|
-
: {}),
|
|
2048
|
+
...sessionFields(endT),
|
|
1994
2049
|
durationMs: waitedMs,
|
|
1995
2050
|
...(error ? { error } : {}),
|
|
1996
2051
|
}, reserveBackdated(waitedMs));
|
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
|
/**
|
package/src/mobile.ts
CHANGED
|
@@ -99,14 +99,18 @@ export async function openMobile(opts: {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* Acquire the persistent phone-emulated session for an app (one per app URL
|
|
103
|
-
* created on first use). The daemon calls this from
|
|
104
|
-
* per-test rrweb recorder; the resulting
|
|
105
|
-
* the dashboard renders a phone bezel.
|
|
106
|
-
* `mobile.close()` destroys the session for
|
|
102
|
+
* Acquire the persistent phone-emulated session for an app (one per app URL
|
|
103
|
+
* per `name`, created on first use). The daemon calls this from
|
|
104
|
+
* `ctx.mobile(app, name?)` with a per-test rrweb recorder; the resulting
|
|
105
|
+
* record carries `frame: "mobile"` so the dashboard renders a phone bezel.
|
|
106
|
+
* `detach` is the test-end hook; `mobile.close()` destroys the session for
|
|
107
|
+
* real.
|
|
107
108
|
*/
|
|
108
109
|
export async function openPersistentMobile(opts: {
|
|
109
110
|
url: string;
|
|
111
|
+
/** Session name — `""` (the default) is the unnamed session every
|
|
112
|
+
* `ctx.mobile(app)` call shares. Two names on one app are two phones. */
|
|
113
|
+
name?: string;
|
|
110
114
|
recorder: BrowserSessionRecorder | null;
|
|
111
115
|
/** Installed before the fresh session's first navigation (ignored on an
|
|
112
116
|
* attached session, which already carries it on the forked holder). */
|
|
@@ -121,6 +125,7 @@ export async function openPersistentMobile(opts: {
|
|
|
121
125
|
}> {
|
|
122
126
|
const { browser, attached, detach } = await acquirePersistentMobileBackend(
|
|
123
127
|
opts.url,
|
|
128
|
+
opts.name ?? "",
|
|
124
129
|
opts.recorder,
|
|
125
130
|
opts.initScript,
|
|
126
131
|
);
|
package/src/recorder.ts
CHANGED
|
@@ -429,6 +429,16 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
429
429
|
* with a `BrowserSessionRecorder` attached (the daemon always does).
|
|
430
430
|
*/
|
|
431
431
|
sessionId?: string;
|
|
432
|
+
/**
|
|
433
|
+
* Author-given name of that session — `ctx.browser("alice")` — absent
|
|
434
|
+
* for the default unnamed browser, so a single-browser test's steps
|
|
435
|
+
* carry no name and render exactly as they always have. Present, it is
|
|
436
|
+
* what the step list badges to say which browser acted. Duplicated here
|
|
437
|
+
* rather than resolved from the session record because the CLI's
|
|
438
|
+
* failure detail renders from persisted events alone, with no replay
|
|
439
|
+
* bundle to look the session up in.
|
|
440
|
+
*/
|
|
441
|
+
sessionName?: string;
|
|
432
442
|
/**
|
|
433
443
|
* Wall-clock `Date.now()` captured at the moment this op *finished*.
|
|
434
444
|
* Lives in the same time base as rrweb's `event.timestamp` fields,
|