@specific.dev/spectest 0.41.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/apple.d.ts +47 -0
- package/dist/components/apple.js +59 -0
- package/dist/components/emulate/service.d.ts +126 -0
- package/dist/components/emulate/service.js +153 -0
- package/dist/components/github.d.ts +52 -0
- package/dist/components/github.js +90 -0
- package/dist/components/google.d.ts +57 -0
- package/dist/components/google.js +92 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +5 -0
- package/dist/components/microsoft.d.ts +55 -0
- package/dist/components/microsoft.js +76 -0
- package/dist/components/okta.d.ts +51 -0
- package/dist/components/okta.js +78 -0
- package/dist/daemon.js +76 -46
- package/dist/index.d.ts +55 -13
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -1
- package/src/browser.ts +88 -33
- package/src/components/apple.ts +65 -0
- package/src/components/emulate/entry.mjs +244 -0
- package/src/components/emulate/service.ts +235 -0
- package/src/components/github.ts +96 -0
- package/src/components/google.ts +104 -0
- package/src/components/index.ts +14 -0
- package/src/components/microsoft.ts +89 -0
- package/src/components/okta.ts +93 -0
- package/src/daemon.ts +87 -39
- package/src/index.ts +55 -13
- package/src/mobile.ts +10 -5
- package/src/recorder.ts +10 -0
package/dist/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));
|