@specific.dev/spectest 0.14.0 → 0.16.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/package.json +1 -1
- package/src/browser.ts +408 -48
- package/src/components/email.ts +398 -0
- package/src/components/index.ts +9 -0
- package/src/components/supabase.ts +216 -26
- package/src/daemon.ts +222 -34
- package/src/index.ts +21 -5
- package/src/mobile.ts +47 -4
- package/src/recorder.ts +73 -1
package/src/daemon.ts
CHANGED
|
@@ -34,8 +34,8 @@ import {
|
|
|
34
34
|
proxy as makeProxyDecl,
|
|
35
35
|
} from "./index.js";
|
|
36
36
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
37
|
+
import { acquirePersistentBrowser } from "./browser.js";
|
|
38
|
+
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
39
39
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
40
40
|
import { openTerminal } from "./terminal.js";
|
|
41
41
|
import {
|
|
@@ -1478,6 +1478,88 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
1478
1478
|
"host",
|
|
1479
1479
|
]);
|
|
1480
1480
|
|
|
1481
|
+
/**
|
|
1482
|
+
* Is this a CORS preflight? A preflight is the browser's own probe (never
|
|
1483
|
+
* app business logic): an `OPTIONS` carrying `Origin` +
|
|
1484
|
+
* `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
|
|
1485
|
+
* app requests and pass straight through to the upstream/fake.
|
|
1486
|
+
*/
|
|
1487
|
+
function isCorsPreflight(req: Request): boolean {
|
|
1488
|
+
return (
|
|
1489
|
+
req.method === "OPTIONS" &&
|
|
1490
|
+
req.headers.has("origin") &&
|
|
1491
|
+
req.headers.has("access-control-request-method")
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Answer a CORS preflight at the ingress, permissively, reflecting exactly
|
|
1497
|
+
* what the browser asked for.
|
|
1498
|
+
*
|
|
1499
|
+
* Why this belongs in the platform, not the app: inside the hermetic sandbox
|
|
1500
|
+
* the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
|
|
1501
|
+
* it fetches through this ingress (`https://api.example.com`) are *always*
|
|
1502
|
+
* different origins, so any request with a non-safelisted header — which
|
|
1503
|
+
* includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
|
|
1504
|
+
* preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
|
|
1505
|
+
* request succeeds or fails on whether *that* app happens to enumerate the
|
|
1506
|
+
* header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
|
|
1507
|
+
* but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
|
|
1508
|
+
* HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
|
|
1509
|
+
* with an instant "Failed to fetch" — even though the identical request works
|
|
1510
|
+
* in production behind a permissive edge/gateway. Reflecting
|
|
1511
|
+
* `Access-Control-Request-Headers` verbatim makes the ingress transparent to
|
|
1512
|
+
* whatever header vocabulary the app under test uses.
|
|
1513
|
+
*/
|
|
1514
|
+
function corsPreflightResponse(req: Request): Response {
|
|
1515
|
+
const origin = req.headers.get("origin") ?? "*";
|
|
1516
|
+
const reqHeaders = req.headers.get("access-control-request-headers");
|
|
1517
|
+
const reqMethod = req.headers.get("access-control-request-method");
|
|
1518
|
+
const headers = new Headers();
|
|
1519
|
+
headers.set("access-control-allow-origin", origin);
|
|
1520
|
+
// Echo the specific origin (not `*`) so credentialed requests are allowed;
|
|
1521
|
+
// `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
|
|
1522
|
+
// reject.
|
|
1523
|
+
headers.set("access-control-allow-credentials", "true");
|
|
1524
|
+
headers.set(
|
|
1525
|
+
"access-control-allow-methods",
|
|
1526
|
+
reqMethod && reqMethod.length > 0
|
|
1527
|
+
? reqMethod
|
|
1528
|
+
: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
|
1529
|
+
);
|
|
1530
|
+
headers.set(
|
|
1531
|
+
"access-control-allow-headers",
|
|
1532
|
+
reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*",
|
|
1533
|
+
);
|
|
1534
|
+
headers.set("access-control-max-age", "600");
|
|
1535
|
+
// The response varies by the reflected origin/headers — keep caches honest.
|
|
1536
|
+
headers.append("vary", "Origin");
|
|
1537
|
+
headers.append("vary", "Access-Control-Request-Headers");
|
|
1538
|
+
return new Response(null, { status: 204, headers });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
|
|
1543
|
+
* *actual* cross-origin response. Only fills one in when the upstream/fake
|
|
1544
|
+
* didn't set its own, so an app that manages CORS itself keeps full control;
|
|
1545
|
+
* this just stops a missing header from turning an otherwise-fine 200 into a
|
|
1546
|
+
* "Failed to fetch". No-op for same-origin requests (no `Origin`).
|
|
1547
|
+
*/
|
|
1548
|
+
function augmentCorsResponse(req: Request, res: Response): Response {
|
|
1549
|
+
const origin = req.headers.get("origin");
|
|
1550
|
+
if (!origin) return res;
|
|
1551
|
+
if (res.headers.has("access-control-allow-origin")) return res;
|
|
1552
|
+
try {
|
|
1553
|
+
res.headers.set("access-control-allow-origin", origin);
|
|
1554
|
+
res.headers.set("access-control-allow-credentials", "true");
|
|
1555
|
+
res.headers.append("vary", "Origin");
|
|
1556
|
+
} catch {
|
|
1557
|
+
// Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
|
|
1558
|
+
// headers — leave those untouched.
|
|
1559
|
+
}
|
|
1560
|
+
return res;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1481
1563
|
/**
|
|
1482
1564
|
* Bring ingress servers up: bind one Bun.serve per unique HTTP port
|
|
1483
1565
|
* (fakes' ports plus the always-on :80 for service proxies), plus a
|
|
@@ -1865,9 +1947,15 @@ async function dispatchIngress(
|
|
|
1865
1947
|
{ status: 404, headers: { "content-type": "text/plain" } },
|
|
1866
1948
|
);
|
|
1867
1949
|
}
|
|
1950
|
+
// Answer CORS preflights at the ingress (see corsPreflightResponse) so a
|
|
1951
|
+
// cross-origin browser request carrying any header — Authorization,
|
|
1952
|
+
// Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
|
|
1953
|
+
// to list in Access-Control-Allow-Headers.
|
|
1954
|
+
if (isCorsPreflight(req)) return corsPreflightResponse(req);
|
|
1868
1955
|
if (route.kind === "fake") {
|
|
1869
1956
|
try {
|
|
1870
|
-
|
|
1957
|
+
const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
|
|
1958
|
+
return augmentCorsResponse(req, res);
|
|
1871
1959
|
} catch (err) {
|
|
1872
1960
|
const e = err as Error;
|
|
1873
1961
|
return new Response(
|
|
@@ -1876,7 +1964,15 @@ async function dispatchIngress(
|
|
|
1876
1964
|
);
|
|
1877
1965
|
}
|
|
1878
1966
|
}
|
|
1879
|
-
|
|
1967
|
+
const res = await proxyToService(
|
|
1968
|
+
req,
|
|
1969
|
+
server,
|
|
1970
|
+
route.service,
|
|
1971
|
+
route.port,
|
|
1972
|
+
listenerLabel,
|
|
1973
|
+
proto,
|
|
1974
|
+
);
|
|
1975
|
+
return augmentCorsResponse(req, res);
|
|
1880
1976
|
}
|
|
1881
1977
|
|
|
1882
1978
|
/**
|
|
@@ -2574,6 +2670,13 @@ async function bootstrap(): Promise<BootstrapTimings> {
|
|
|
2574
2670
|
// browser.ts:213 (the long-standing intermittent NAME_NOT_RESOLVED) and
|
|
2575
2671
|
// the clocksource-regression notes. Re-enabling requires fixing the
|
|
2576
2672
|
// restored-renderer DNS state, not just re-adding the prewarm call.
|
|
2673
|
+
// NOTE: persistent sessions (ctx.browser/ctx.mobile keep one live view
|
|
2674
|
+
// across tests, so restored forks navigate on a pre-snapshot renderer
|
|
2675
|
+
// routinely) hit the same bug head-on; browser.ts handles it there by
|
|
2676
|
+
// rebuilding the view in the same Chrome and retrying the navigation
|
|
2677
|
+
// (`rebuildView`) — profile state survives, so auth carries over. That
|
|
2678
|
+
// recovery is scoped to inherited-navigation failures and does NOT make
|
|
2679
|
+
// the about:blank prewarm pool safe to re-enable.
|
|
2577
2680
|
|
|
2578
2681
|
const result: BootstrapTimings = {
|
|
2579
2682
|
totalMs: Date.now() - bootStart,
|
|
@@ -3645,22 +3748,49 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3645
3748
|
const parentId = testCase.dependsOn?.id;
|
|
3646
3749
|
const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
|
|
3647
3750
|
|
|
3648
|
-
//
|
|
3649
|
-
//
|
|
3650
|
-
//
|
|
3651
|
-
//
|
|
3652
|
-
//
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
//
|
|
3656
|
-
|
|
3751
|
+
// Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
|
|
3752
|
+
// shared desktop browser and `ctx.mobile(app)` the one session for that
|
|
3753
|
+
// app (browser.ts's module-scoped registry, which forks with the
|
|
3754
|
+
// snapshot like fake state). At test end we DETACH — final rrweb drain,
|
|
3755
|
+
// stop writing to this test's recorder — but deliberately keep the
|
|
3756
|
+
// Chromium alive so the post-test snapshot captures it and dependsOn
|
|
3757
|
+
// children resume the live page (cookies, localStorage, signed-in SPA
|
|
3758
|
+
// state) instead of re-navigating. Each test still gets its own session
|
|
3759
|
+
// record (attach re-arms rrweb with a fresh full snapshot, so replays
|
|
3760
|
+
// stay per-case self-contained); records flow back to the control plane
|
|
3761
|
+
// on RunResult.browserSessions and are archived to S3 as the case's
|
|
3762
|
+
// replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
|
|
3763
|
+
// calls return the same handle (memoized below) so one test = one
|
|
3764
|
+
// session per device. An explicit `.close()` destroys the shared
|
|
3765
|
+
// instance — the memo is cleared so a later call starts fresh.
|
|
3766
|
+
const browserDetaches: Array<() => Promise<void>> = [];
|
|
3657
3767
|
const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
|
|
3768
|
+
let sharedBrowser: Browser | null = null;
|
|
3769
|
+
const sharedMobiles = new Map<string, Mobile>();
|
|
3658
3770
|
const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
|
|
3771
|
+
if (sharedBrowser) return sharedBrowser;
|
|
3659
3772
|
const session = newBrowserSession(start, testCase.id);
|
|
3660
3773
|
sessions.push(session);
|
|
3661
|
-
const
|
|
3662
|
-
|
|
3663
|
-
|
|
3774
|
+
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
3775
|
+
...(opts ?? {}),
|
|
3776
|
+
recorder: session.recorder,
|
|
3777
|
+
});
|
|
3778
|
+
// An attached session starts mid-page (no navigate event will fire) —
|
|
3779
|
+
// stamp the inherited URL so the dashboard can still label the replay.
|
|
3780
|
+
if (attached && session.record.initialUrl === undefined) {
|
|
3781
|
+
session.record.initialUrl = browser.url;
|
|
3782
|
+
}
|
|
3783
|
+
browserDetaches.push(async () => {
|
|
3784
|
+
await detach();
|
|
3785
|
+
session.markClosed();
|
|
3786
|
+
});
|
|
3787
|
+
const innerClose = browser.close.bind(browser);
|
|
3788
|
+
browser.close = async () => {
|
|
3789
|
+
await innerClose();
|
|
3790
|
+
if (sharedBrowser === browser) sharedBrowser = null;
|
|
3791
|
+
};
|
|
3792
|
+
sharedBrowser = browser;
|
|
3793
|
+
return browser;
|
|
3664
3794
|
};
|
|
3665
3795
|
const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
|
|
3666
3796
|
if (!isMobileApp(app)) {
|
|
@@ -3668,11 +3798,28 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3668
3798
|
"ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
|
|
3669
3799
|
);
|
|
3670
3800
|
}
|
|
3801
|
+
const existing = sharedMobiles.get(app.url);
|
|
3802
|
+
if (existing) return existing;
|
|
3671
3803
|
const session = newBrowserSession(start, testCase.id, "mobile");
|
|
3672
3804
|
sessions.push(session);
|
|
3673
|
-
const
|
|
3674
|
-
|
|
3675
|
-
|
|
3805
|
+
const { mobile, attached, detach } = await openPersistentMobile({
|
|
3806
|
+
url: app.url,
|
|
3807
|
+
recorder: session.recorder,
|
|
3808
|
+
});
|
|
3809
|
+
if (attached && session.record.initialUrl === undefined) {
|
|
3810
|
+
session.record.initialUrl = mobile.url;
|
|
3811
|
+
}
|
|
3812
|
+
browserDetaches.push(async () => {
|
|
3813
|
+
await detach();
|
|
3814
|
+
session.markClosed();
|
|
3815
|
+
});
|
|
3816
|
+
const innerClose = mobile.close.bind(mobile);
|
|
3817
|
+
mobile.close = async () => {
|
|
3818
|
+
await innerClose();
|
|
3819
|
+
if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
|
|
3820
|
+
};
|
|
3821
|
+
sharedMobiles.set(app.url, mobile);
|
|
3822
|
+
return mobile;
|
|
3676
3823
|
};
|
|
3677
3824
|
|
|
3678
3825
|
// Build convenience handles (e.g. ctx.svc.db.client) from the loaded
|
|
@@ -3740,13 +3887,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3740
3887
|
(process.stdout as any).write = origStdout;
|
|
3741
3888
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3742
3889
|
(process.stderr as any).write = origStderr;
|
|
3743
|
-
//
|
|
3744
|
-
// before
|
|
3745
|
-
//
|
|
3746
|
-
//
|
|
3747
|
-
|
|
3890
|
+
// Detach every browser/mobile session: final rrweb drain (must be
|
|
3891
|
+
// awaited before collecting session records), then stop writing to
|
|
3892
|
+
// this test's recorder. The Chromium itself deliberately stays alive
|
|
3893
|
+
// — it's part of the state the post-test snapshot captures for
|
|
3894
|
+
// dependsOn children (see the acquire comment above).
|
|
3895
|
+
for (const detach of browserDetaches) {
|
|
3748
3896
|
try {
|
|
3749
|
-
await
|
|
3897
|
+
await detach();
|
|
3750
3898
|
} catch {
|
|
3751
3899
|
/* ignore */
|
|
3752
3900
|
}
|
|
@@ -3926,14 +4074,35 @@ async function evalCode(
|
|
|
3926
4074
|
// wrapped type is honest at runtime). Restored in the `finally` below.
|
|
3927
4075
|
const restoreFetch = installFetchWrapper();
|
|
3928
4076
|
|
|
3929
|
-
|
|
4077
|
+
// Same persistent acquire/detach as a test run (see runOne): the browser
|
|
4078
|
+
// survives the eval, so successive `spectest env eval` calls continue one
|
|
4079
|
+
// live session — and a snapshot taken afterwards carries it.
|
|
4080
|
+
const browserDetaches: Array<() => Promise<void>> = [];
|
|
3930
4081
|
const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
|
|
4082
|
+
let sharedBrowser: Browser | null = null;
|
|
4083
|
+
const sharedMobiles = new Map<string, Mobile>();
|
|
3931
4084
|
const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
|
|
4085
|
+
if (sharedBrowser) return sharedBrowser;
|
|
3932
4086
|
const session = newBrowserSession(start, "eval");
|
|
3933
4087
|
sessions.push(session);
|
|
3934
|
-
const
|
|
3935
|
-
|
|
3936
|
-
|
|
4088
|
+
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4089
|
+
...(opts ?? {}),
|
|
4090
|
+
recorder: session.recorder,
|
|
4091
|
+
});
|
|
4092
|
+
if (attached && session.record.initialUrl === undefined) {
|
|
4093
|
+
session.record.initialUrl = browser.url;
|
|
4094
|
+
}
|
|
4095
|
+
browserDetaches.push(async () => {
|
|
4096
|
+
await detach();
|
|
4097
|
+
session.markClosed();
|
|
4098
|
+
});
|
|
4099
|
+
const innerClose = browser.close.bind(browser);
|
|
4100
|
+
browser.close = async () => {
|
|
4101
|
+
await innerClose();
|
|
4102
|
+
if (sharedBrowser === browser) sharedBrowser = null;
|
|
4103
|
+
};
|
|
4104
|
+
sharedBrowser = browser;
|
|
4105
|
+
return browser;
|
|
3937
4106
|
};
|
|
3938
4107
|
const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
|
|
3939
4108
|
if (!isMobileApp(app)) {
|
|
@@ -3941,11 +4110,28 @@ async function evalCode(
|
|
|
3941
4110
|
"ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
|
|
3942
4111
|
);
|
|
3943
4112
|
}
|
|
4113
|
+
const existing = sharedMobiles.get(app.url);
|
|
4114
|
+
if (existing) return existing;
|
|
3944
4115
|
const session = newBrowserSession(start, "eval", "mobile");
|
|
3945
4116
|
sessions.push(session);
|
|
3946
|
-
const
|
|
3947
|
-
|
|
3948
|
-
|
|
4117
|
+
const { mobile, attached, detach } = await openPersistentMobile({
|
|
4118
|
+
url: app.url,
|
|
4119
|
+
recorder: session.recorder,
|
|
4120
|
+
});
|
|
4121
|
+
if (attached && session.record.initialUrl === undefined) {
|
|
4122
|
+
session.record.initialUrl = mobile.url;
|
|
4123
|
+
}
|
|
4124
|
+
browserDetaches.push(async () => {
|
|
4125
|
+
await detach();
|
|
4126
|
+
session.markClosed();
|
|
4127
|
+
});
|
|
4128
|
+
const innerClose = mobile.close.bind(mobile);
|
|
4129
|
+
mobile.close = async () => {
|
|
4130
|
+
await innerClose();
|
|
4131
|
+
if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
|
|
4132
|
+
};
|
|
4133
|
+
sharedMobiles.set(app.url, mobile);
|
|
4134
|
+
return mobile;
|
|
3949
4135
|
};
|
|
3950
4136
|
|
|
3951
4137
|
// Terminal sessions — same shape as runOne, but eval has no active
|
|
@@ -4058,9 +4244,11 @@ async function evalCode(
|
|
|
4058
4244
|
(process.stdout as any).write = origStdout;
|
|
4059
4245
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4060
4246
|
(process.stderr as any).write = origStderr;
|
|
4061
|
-
|
|
4247
|
+
// Detach (final rrweb drain) — the browser itself stays alive; see
|
|
4248
|
+
// the acquire comment above.
|
|
4249
|
+
for (const detach of browserDetaches) {
|
|
4062
4250
|
try {
|
|
4063
|
-
await
|
|
4251
|
+
await detach();
|
|
4064
4252
|
} catch {
|
|
4065
4253
|
/* ignore */
|
|
4066
4254
|
}
|
package/src/index.ts
CHANGED
|
@@ -1061,9 +1061,20 @@ export interface TestContext<
|
|
|
1061
1061
|
*/
|
|
1062
1062
|
openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
|
|
1063
1063
|
/**
|
|
1064
|
-
* Open
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1064
|
+
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1065
|
+
*
|
|
1066
|
+
* There is ONE persistent browser per environment: every `ctx.browser()`
|
|
1067
|
+
* call returns it, and it stays alive across tests — the browser is part
|
|
1068
|
+
* of the state a test's snapshot captures, so a `dependsOn` child resumes
|
|
1069
|
+
* the exact live page its parent left (cookies, localStorage, signed-in
|
|
1070
|
+
* SPA state). Sign in once in a parent test; every descendant is already
|
|
1071
|
+
* signed in. Sibling tests fork from the same parent snapshot, so they
|
|
1072
|
+
* can't see each other's browsing. A test with no browser-using ancestor
|
|
1073
|
+
* gets a fresh browser on first call (first call's options win).
|
|
1074
|
+
*
|
|
1075
|
+
* `.close()` destroys the shared instance — the next `ctx.browser()`
|
|
1076
|
+
* starts fresh. Don't call it for routine cleanup; recording is detached
|
|
1077
|
+
* automatically at test end.
|
|
1067
1078
|
*/
|
|
1068
1079
|
browser(opts?: BrowserOptions): Promise<Browser>;
|
|
1069
1080
|
/**
|
|
@@ -1082,8 +1093,13 @@ export interface TestContext<
|
|
|
1082
1093
|
* ```
|
|
1083
1094
|
*
|
|
1084
1095
|
* The session emulates the latest iPhone (viewport + DPR + mobile UA +
|
|
1085
|
-
* touch) and the dashboard replays it inside a phone bezel.
|
|
1086
|
-
*
|
|
1096
|
+
* touch) and the dashboard replays it inside a phone bezel.
|
|
1097
|
+
*
|
|
1098
|
+
* Sessions are persistent, one per app: like `ctx.browser()`, the live
|
|
1099
|
+
* session is captured in the test's snapshot, so a `dependsOn` child
|
|
1100
|
+
* picks up the app exactly where the parent left it (already signed in,
|
|
1101
|
+
* mid-flow) instead of reloading it. `.close()` discards the session;
|
|
1102
|
+
* the next `ctx.mobile(app)` opens the app fresh.
|
|
1087
1103
|
*/
|
|
1088
1104
|
mobile(app: MobileApp): Promise<Mobile>;
|
|
1089
1105
|
/** The test's display name. */
|
package/src/mobile.ts
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
// `accessibilityLabel` to `aria-label`, so `getByTestId`/`getByLabel` map to
|
|
15
15
|
// plain DOM attribute selectors with no shimming.
|
|
16
16
|
|
|
17
|
-
import { openMobileBackend } from "./browser.js";
|
|
17
|
+
import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js";
|
|
18
18
|
import type {
|
|
19
19
|
BrowserSessionRecorder,
|
|
20
20
|
MobileBackend,
|
|
21
|
+
SafeAreaInsets,
|
|
21
22
|
ScreenshotOptions,
|
|
22
23
|
} from "./browser.js";
|
|
23
24
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -278,6 +279,14 @@ export interface Mobile {
|
|
|
278
279
|
back(): Promise<void>;
|
|
279
280
|
/** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
|
|
280
281
|
evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
|
|
282
|
+
/**
|
|
283
|
+
* Install a script that runs before every *subsequent* document's own
|
|
284
|
+
* scripts (the Playwright `addInitScript` equivalent) — the deterministic
|
|
285
|
+
* way to plant shims/instrumentation that must win the race against the
|
|
286
|
+
* app bundle. Takes effect on the next navigation (e.g. a
|
|
287
|
+
* `location.assign` deep link), not the current document.
|
|
288
|
+
*/
|
|
289
|
+
addInitScript(description: string, source: string): Promise<void>;
|
|
281
290
|
/** Low-level escape hatch: poll a JS expression until truthy (recorded). */
|
|
282
291
|
waitFor<T = unknown>(
|
|
283
292
|
description: string,
|
|
@@ -349,6 +358,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
|
|
|
349
358
|
evaluate(description, script) {
|
|
350
359
|
return backend.evaluate(description, script);
|
|
351
360
|
},
|
|
361
|
+
addInitScript(description, source) {
|
|
362
|
+
return backend.addInitScript(description, source);
|
|
363
|
+
},
|
|
352
364
|
waitFor(description, expression, options) {
|
|
353
365
|
return backend.waitFor(description, expression, options);
|
|
354
366
|
},
|
|
@@ -362,9 +374,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
|
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
/**
|
|
365
|
-
* Open
|
|
366
|
-
* `ctx.mobile(app)`
|
|
367
|
-
*
|
|
377
|
+
* Open an EPHEMERAL phone-emulated session pointed at `url` (`close()`
|
|
378
|
+
* destroys it). Library callers only — the daemon's `ctx.mobile(app)` goes
|
|
379
|
+
* through {@link openPersistentMobile} so sessions survive across tests.
|
|
368
380
|
*/
|
|
369
381
|
export async function openMobile(opts: {
|
|
370
382
|
url?: string;
|
|
@@ -377,3 +389,34 @@ export async function openMobile(opts: {
|
|
|
377
389
|
});
|
|
378
390
|
return wrapMobile(backend);
|
|
379
391
|
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Acquire the persistent phone-emulated session for an app (one per app
|
|
395
|
+
* URL, created on first use — see the "Persistent sessions" section in
|
|
396
|
+
* browser.ts). The daemon calls this from `ctx.mobile(app)` with a
|
|
397
|
+
* per-test rrweb recorder; the resulting record carries `frame: "mobile"`
|
|
398
|
+
* so the dashboard renders a phone bezel. `detach` is the test-end hook;
|
|
399
|
+
* `mobile.close()` destroys the session for real.
|
|
400
|
+
*/
|
|
401
|
+
export async function openPersistentMobile(opts: {
|
|
402
|
+
url: string;
|
|
403
|
+
recorder: BrowserSessionRecorder | null;
|
|
404
|
+
}): Promise<{
|
|
405
|
+
mobile: Mobile;
|
|
406
|
+
attached: boolean;
|
|
407
|
+
detach(): Promise<void>;
|
|
408
|
+
/** Safe-area insets emulated on the view (`null` when the CDP override
|
|
409
|
+
* is unavailable) — the daemon stamps them onto the session record. */
|
|
410
|
+
safeAreaInsets: SafeAreaInsets | null;
|
|
411
|
+
}> {
|
|
412
|
+
const { browser, attached, detach } = await acquirePersistentMobileBackend(
|
|
413
|
+
opts.url,
|
|
414
|
+
opts.recorder,
|
|
415
|
+
);
|
|
416
|
+
return {
|
|
417
|
+
mobile: wrapMobile(browser),
|
|
418
|
+
attached,
|
|
419
|
+
detach,
|
|
420
|
+
safeAreaInsets: browser.safeAreaInsets,
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type TestEvent =
|
|
|
25
25
|
| TerminalStepEvent
|
|
26
26
|
| WaitEvent
|
|
27
27
|
| FakeEvent
|
|
28
|
-
| EnvEvent
|
|
28
|
+
| EnvEvent
|
|
29
|
+
| EmailEvent;
|
|
29
30
|
|
|
30
31
|
interface BaseEvent {
|
|
31
32
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
@@ -275,6 +276,69 @@ export interface FakeEvent extends BaseEvent {
|
|
|
275
276
|
error?: string;
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/**
|
|
280
|
+
* One captured email, as embedded on an {@link EmailEvent}. The HTML and
|
|
281
|
+
* text bodies ride along (truncated to the output cap) so the dashboard
|
|
282
|
+
* can render the actual email a test asserted against.
|
|
283
|
+
*/
|
|
284
|
+
export interface EmailEventMessage {
|
|
285
|
+
/** Sender address. */
|
|
286
|
+
from?: string;
|
|
287
|
+
/** Recipient addresses. */
|
|
288
|
+
to?: string[];
|
|
289
|
+
cc?: string[];
|
|
290
|
+
bcc?: string[];
|
|
291
|
+
subject?: string;
|
|
292
|
+
/** RFC date of the message, ISO-formatted. */
|
|
293
|
+
date?: string;
|
|
294
|
+
/** HTML body (truncated to the output cap). */
|
|
295
|
+
html?: string;
|
|
296
|
+
htmlTruncated?: boolean;
|
|
297
|
+
/** Plain-text body (truncated to the output cap). */
|
|
298
|
+
text?: string;
|
|
299
|
+
textTruncated?: boolean;
|
|
300
|
+
attachments?: { filename: string; contentType: string; size: number }[];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One row of a mailbox listing embedded on an {@link EmailEvent}. */
|
|
304
|
+
export interface EmailEventSummary {
|
|
305
|
+
from?: string;
|
|
306
|
+
to?: string[];
|
|
307
|
+
subject?: string;
|
|
308
|
+
/** Plain-text preview of the body. */
|
|
309
|
+
snippet?: string;
|
|
310
|
+
date?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* One call to an email service's helpers (`ctx.svc.<name>.lastEmail(...)`
|
|
315
|
+
* etc.). Single-message ops embed the full captured message — including its
|
|
316
|
+
* HTML body — so timelines can render the email itself; listing ops embed
|
|
317
|
+
* compact summaries. The return value is `wrap()`ped against this event's
|
|
318
|
+
* seq, so `expect(...)` on it nests under this step (same mechanism as
|
|
319
|
+
* http/db/fake). Inside a `ctx.poll` predicate the event ride-alongs with
|
|
320
|
+
* the poll's iteration events: failed iterations get truncated, the winning
|
|
321
|
+
* one survives as a child of the `wait` event.
|
|
322
|
+
*/
|
|
323
|
+
export interface EmailEvent extends BaseEvent {
|
|
324
|
+
kind: "email";
|
|
325
|
+
/** Service key of the mail server (`ctx.svc.<service>`). */
|
|
326
|
+
service: string;
|
|
327
|
+
/** Helper called, e.g. `"lastEmail"`. */
|
|
328
|
+
op: string;
|
|
329
|
+
/** Human-readable match criteria, e.g. `to alice@example.com`. */
|
|
330
|
+
query?: string;
|
|
331
|
+
/** Number of matching messages (listing ops / mailbox size on error). */
|
|
332
|
+
count?: number;
|
|
333
|
+
/** The captured message (single-message ops). */
|
|
334
|
+
message?: EmailEventMessage;
|
|
335
|
+
/** Message summaries (listing ops). */
|
|
336
|
+
messages?: EmailEventSummary[];
|
|
337
|
+
durationMs: number;
|
|
338
|
+
/** Set if the op threw (e.g. the mail server's query API failed). */
|
|
339
|
+
error?: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
278
342
|
/**
|
|
279
343
|
* A runtime mutation of the environment — `ctx.startService` /
|
|
280
344
|
* `ctx.stopService` / `ctx.dnsName`, whether called from a test or from a
|
|
@@ -303,6 +367,7 @@ export interface EnvEvent extends BaseEvent {
|
|
|
303
367
|
export type BrowserAction =
|
|
304
368
|
| "navigate"
|
|
305
369
|
| "evaluate"
|
|
370
|
+
| "addInitScript"
|
|
306
371
|
| "waitFor"
|
|
307
372
|
| "click"
|
|
308
373
|
| "type"
|
|
@@ -663,6 +728,13 @@ export function recordEnv(
|
|
|
663
728
|
return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
664
729
|
}
|
|
665
730
|
|
|
731
|
+
export function recordEmail(
|
|
732
|
+
ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
|
|
733
|
+
reservation?: EventReservation,
|
|
734
|
+
): number | undefined {
|
|
735
|
+
return active() ? current!.push({ kind: "email", ...ev }, reservation) : undefined;
|
|
736
|
+
}
|
|
737
|
+
|
|
666
738
|
/**
|
|
667
739
|
* Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
|
|
668
740
|
* intermediate iterations and gives downstream tagged values
|