@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/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
// Headless-Linux specifics live here: we force the chrome backend, add
|
|
17
17
|
// `--no-sandbox` (Chrome refuses to run as root otherwise) and
|
|
18
18
|
// `--disable-dev-shm-usage` (Firecracker's /dev/shm is tiny).
|
|
19
|
+
//
|
|
20
|
+
// Sessions are PERSISTENT by default: `ctx.browser()` always hands back the
|
|
21
|
+
// one shared desktop browser and `ctx.mobile(app)` the one session for that
|
|
22
|
+
// app, kept alive across tests so snapshots capture the live Chromium and
|
|
23
|
+
// dependsOn children resume exactly where the parent left off (signed-in
|
|
24
|
+
// SPA state included). See the "Persistent sessions" section below.
|
|
19
25
|
|
|
26
|
+
import { promises as dns } from "node:dns";
|
|
20
27
|
import { readFileSync } from "node:fs";
|
|
21
28
|
import path from "node:path";
|
|
22
29
|
import { fileURLToPath } from "node:url";
|
|
@@ -160,6 +167,24 @@ export interface Browser {
|
|
|
160
167
|
* test event log so the step list isn't a wall of minified code.
|
|
161
168
|
*/
|
|
162
169
|
evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
|
|
170
|
+
/**
|
|
171
|
+
* Install a script that runs in every document loaded from now on, BEFORE
|
|
172
|
+
* any of the document's own scripts execute (CDP
|
|
173
|
+
* `Page.addScriptToEvaluateOnNewDocument` — the Playwright `addInitScript`
|
|
174
|
+
* equivalent). The deterministic way to plant shims and instrumentation
|
|
175
|
+
* (`fetch`/`XMLHttpRequest` wrappers, clock stubs, feature flags): unlike an
|
|
176
|
+
* `evaluate` racing the app bundle after a navigation, an init script is
|
|
177
|
+
* guaranteed to win.
|
|
178
|
+
*
|
|
179
|
+
* Does NOT run in the *current* document — call it before the `navigate`
|
|
180
|
+
* (or in-page `location.assign`) whose document needs it. Installed
|
|
181
|
+
* scripts persist for the browser session's lifetime, which for the
|
|
182
|
+
* persistent `ctx.browser()`/`ctx.mobile()` sessions means they ride
|
|
183
|
+
* snapshots into `dependsOn` children like the rest of the session state.
|
|
184
|
+
*
|
|
185
|
+
* `description` labels the step in the test event log.
|
|
186
|
+
*/
|
|
187
|
+
addInitScript(description: string, source: string): Promise<void>;
|
|
163
188
|
/**
|
|
164
189
|
* Poll `expression` in the page until it returns a truthy value
|
|
165
190
|
* (the returned value is what `waitFor` resolves with). Useful for
|
|
@@ -205,7 +230,15 @@ export interface Browser {
|
|
|
205
230
|
reload(): Promise<void>;
|
|
206
231
|
/** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
|
|
207
232
|
screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
|
|
208
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* Destroy the underlying view. Idempotent; drains any pending rrweb
|
|
235
|
+
* events first. For the persistent session behind `ctx.browser()` /
|
|
236
|
+
* `ctx.mobile()` this is the escape hatch to a FRESH browser — the
|
|
237
|
+
* shared instance is discarded and the next call creates a new one.
|
|
238
|
+
* Don't call it for routine cleanup: the daemon detaches recording at
|
|
239
|
+
* test end automatically and deliberately keeps the browser alive so
|
|
240
|
+
* dependent tests inherit its state.
|
|
241
|
+
*/
|
|
209
242
|
close(): Promise<void>;
|
|
210
243
|
}
|
|
211
244
|
|
|
@@ -218,6 +251,10 @@ export interface Browser {
|
|
|
218
251
|
* drain as the desktop verbs.
|
|
219
252
|
*/
|
|
220
253
|
export interface MobileBackend extends Browser {
|
|
254
|
+
/** Safe-area insets emulated on this view (`null` on desktop views or
|
|
255
|
+
* when the CDP override is unavailable). The daemon stamps these onto
|
|
256
|
+
* the session record for the dashboard's replay. */
|
|
257
|
+
readonly safeAreaInsets: SafeAreaInsets | null;
|
|
221
258
|
/** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
|
|
222
259
|
tapAt(x: number, y: number): Promise<void>;
|
|
223
260
|
/** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
|
|
@@ -266,6 +303,15 @@ interface DevicePreset {
|
|
|
266
303
|
isMobile: boolean;
|
|
267
304
|
hasTouch: boolean;
|
|
268
305
|
userAgent: string;
|
|
306
|
+
safeAreaInsets: SafeAreaInsets;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
|
|
310
|
+
export interface SafeAreaInsets {
|
|
311
|
+
top: number;
|
|
312
|
+
right: number;
|
|
313
|
+
bottom: number;
|
|
314
|
+
left: number;
|
|
269
315
|
}
|
|
270
316
|
|
|
271
317
|
/** The fixed mobile device. Logical resolution + DPR of a current iPhone;
|
|
@@ -281,16 +327,28 @@ const LATEST_IPHONE: DevicePreset = {
|
|
|
281
327
|
userAgent:
|
|
282
328
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
|
|
283
329
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
330
|
+
// Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
|
|
331
|
+
// status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
|
|
332
|
+
// at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
|
|
333
|
+
// padding fires exactly like on the real device.
|
|
334
|
+
safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
|
|
284
335
|
};
|
|
285
336
|
|
|
286
337
|
/** Apply CDP device emulation to a freshly-created view. The overrides are
|
|
287
338
|
* CDP-session-global, so they persist across the app navigation that
|
|
288
339
|
* follows (we set them on the about:blank bootstrap page). Best-effort:
|
|
289
|
-
* a CDP failure degrades to a plain desktop view rather than aborting.
|
|
340
|
+
* a CDP failure degrades to a plain desktop view rather than aborting.
|
|
341
|
+
*
|
|
342
|
+
* Returns the safe-area insets that actually took effect (`null` when the
|
|
343
|
+
* override failed — Chromium < 135 lacks the CDP method). The caller
|
|
344
|
+
* stamps them onto the session record so the dashboard can substitute the
|
|
345
|
+
* same values for `env(safe-area-inset-*)` in the replayed CSS; stamping
|
|
346
|
+
* only what was really applied keeps capture layout and replay layout in
|
|
347
|
+
* lockstep (recorded touch coordinates would misalign otherwise). */
|
|
290
348
|
async function applyDeviceEmulation(
|
|
291
349
|
view: BunWebViewInstance,
|
|
292
350
|
d: DevicePreset,
|
|
293
|
-
): Promise<
|
|
351
|
+
): Promise<SafeAreaInsets | null> {
|
|
294
352
|
try {
|
|
295
353
|
await view.cdp("Emulation.setDeviceMetricsOverride", {
|
|
296
354
|
width: d.viewport.width,
|
|
@@ -308,6 +366,17 @@ async function applyDeviceEmulation(
|
|
|
308
366
|
} catch (err) {
|
|
309
367
|
// eslint-disable-next-line no-console
|
|
310
368
|
console.warn("[spectest] device emulation failed; using desktop view:", err);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
await view.cdp("Emulation.setSafeAreaInsetsOverride", {
|
|
373
|
+
insets: { ...d.safeAreaInsets },
|
|
374
|
+
});
|
|
375
|
+
return d.safeAreaInsets;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
// eslint-disable-next-line no-console
|
|
378
|
+
console.warn("[spectest] safe-area inset emulation unavailable:", err);
|
|
379
|
+
return null;
|
|
311
380
|
}
|
|
312
381
|
}
|
|
313
382
|
|
|
@@ -603,6 +672,29 @@ interface PooledView {
|
|
|
603
672
|
recordingInstalled: boolean;
|
|
604
673
|
}
|
|
605
674
|
|
|
675
|
+
/**
|
|
676
|
+
* The unit a Browser/Mobile handle drives. `view` is deliberately mutable:
|
|
677
|
+
* the DNS-recovery path (see `navigate` in {@link buildBackend}) replaces a
|
|
678
|
+
* broken restored renderer with a freshly-spawned view in the same Chrome,
|
|
679
|
+
* and every wrapper reads through the holder so the swap is transparent.
|
|
680
|
+
* `device`/`width`/`height` are kept so a rebuilt view comes back with the
|
|
681
|
+
* same viewport and emulation.
|
|
682
|
+
*/
|
|
683
|
+
interface ViewHolder {
|
|
684
|
+
view: BunWebViewInstance;
|
|
685
|
+
recordingInstalled: boolean;
|
|
686
|
+
device: DevicePreset | null;
|
|
687
|
+
width: number;
|
|
688
|
+
height: number;
|
|
689
|
+
/** Safe-area insets actually applied to the view (`null` for desktop
|
|
690
|
+
* views or when the CDP override is unavailable). Stamped onto the
|
|
691
|
+
* session record so the replay can mirror them. */
|
|
692
|
+
safeAreaInsets: SafeAreaInsets | null;
|
|
693
|
+
/** User scripts installed via `addInitScript`, kept so the DNS-recovery
|
|
694
|
+
* rebuild can re-install them on the replacement view. */
|
|
695
|
+
initScripts: string[];
|
|
696
|
+
}
|
|
697
|
+
|
|
606
698
|
// Pre-opened view pool. Renderer spawn is the expensive part of
|
|
607
699
|
// `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
|
|
608
700
|
// (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
|
|
@@ -659,6 +751,11 @@ export async function prewarmViewPool(n = 1): Promise<void> {
|
|
|
659
751
|
* Open a browser view. Always uses the Chrome backend in the daemon
|
|
660
752
|
* (Firecracker guest is Linux; WKWebView isn't available). Serves from
|
|
661
753
|
* the pre-opened pool when the caller uses the default viewport.
|
|
754
|
+
*
|
|
755
|
+
* This is the EPHEMERAL path — `close()` destroys the view. The daemon's
|
|
756
|
+
* `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
|
|
757
|
+
* / {@link acquirePersistentMobileBackend} instead, which keep one view
|
|
758
|
+
* alive across tests so it rides snapshots/forks.
|
|
662
759
|
*/
|
|
663
760
|
export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
|
|
664
761
|
return openMobileBackend(opts);
|
|
@@ -668,10 +765,9 @@ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
|
|
|
668
765
|
* Like {@link openBrowser} but returns the {@link MobileBackend} superset
|
|
669
766
|
* (touch + probe). When `opts.frame === "mobile"` the view is created at the
|
|
670
767
|
* fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
|
|
671
|
-
* device emulation applied before the first navigation.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* object.
|
|
768
|
+
* device emulation applied before the first navigation. Desktop callers go
|
|
769
|
+
* through {@link openBrowser} and get the narrower {@link Browser} view of
|
|
770
|
+
* the same object.
|
|
675
771
|
*/
|
|
676
772
|
export async function openMobileBackend(
|
|
677
773
|
opts: BrowserOptions = {},
|
|
@@ -683,27 +779,242 @@ export async function openMobileBackend(
|
|
|
683
779
|
// views, and a mobile view needs its emulation applied fresh anyway.
|
|
684
780
|
const pooled =
|
|
685
781
|
!device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
686
|
-
const
|
|
782
|
+
const base = pooled ?? (await createView(wantW, wantH));
|
|
783
|
+
const holder: ViewHolder = {
|
|
784
|
+
view: base.view,
|
|
785
|
+
recordingInstalled: base.recordingInstalled,
|
|
786
|
+
device,
|
|
787
|
+
width: wantW,
|
|
788
|
+
height: wantH,
|
|
789
|
+
safeAreaInsets: null,
|
|
790
|
+
initScripts: [],
|
|
791
|
+
};
|
|
687
792
|
|
|
688
|
-
if (device) await applyDeviceEmulation(view, device);
|
|
793
|
+
if (device) holder.safeAreaInsets = await applyDeviceEmulation(holder.view, device);
|
|
689
794
|
|
|
690
|
-
const
|
|
795
|
+
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
796
|
+
persistent: false,
|
|
797
|
+
});
|
|
691
798
|
|
|
692
799
|
// We deliberately don't forward `opts.url` to the constructor — going
|
|
693
800
|
// through our own `navigate()` keeps the recorder log uniform (one
|
|
694
801
|
// event per navigation, with timing) and drains rrweb after the load.
|
|
695
802
|
if (opts.url !== undefined) {
|
|
696
|
-
await
|
|
803
|
+
await backend.navigate(opts.url);
|
|
697
804
|
}
|
|
698
|
-
return
|
|
805
|
+
return backend;
|
|
699
806
|
}
|
|
700
807
|
|
|
701
|
-
|
|
702
|
-
|
|
808
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
809
|
+
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
810
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
// One long-lived desktop browser plus one mobile session per app URL.
|
|
813
|
+
// Module state lives in daemon memory, so it forks with the snapshot the
|
|
814
|
+
// same way fake `state` and TEST_DATA do: a test's browser — its live
|
|
815
|
+
// page, cookies, localStorage, in-memory SPA state — is captured in the
|
|
816
|
+
// post-test snapshot and inherited by `dependsOn` children, while sibling
|
|
817
|
+
// forks never see each other's sessions. That's what lets a child test
|
|
818
|
+
// continue where its parent left off (e.g. already signed in) instead of
|
|
819
|
+
// re-navigating and re-authenticating.
|
|
820
|
+
let SHARED_BROWSER: ViewHolder | null = null;
|
|
821
|
+
const SHARED_MOBILE = new Map<string, ViewHolder>();
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* What acquiring a persistent session returns. `detach` is the test-end
|
|
825
|
+
* hook (final rrweb drain, stop writing to this test's recorder, keep the
|
|
826
|
+
* view alive); `browser.close()` is the author-facing escape hatch that
|
|
827
|
+
* actually destroys the view (the next `ctx.browser()` starts fresh).
|
|
828
|
+
*/
|
|
829
|
+
export interface PersistentBrowser {
|
|
830
|
+
browser: MobileBackend;
|
|
831
|
+
/** True when this call attached to a view inherited from an earlier
|
|
832
|
+
* test (possibly across a snapshot fork) rather than creating one. */
|
|
833
|
+
attached: boolean;
|
|
834
|
+
/** Final rrweb drain + detach from the current recorder. The view stays
|
|
835
|
+
* alive so the post-test snapshot captures it. Idempotent. */
|
|
836
|
+
detach(): Promise<void>;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Runs in the page when a persistent view is attached to a new test's
|
|
840
|
+
// recorder: drop whatever rrweb buffered since the previous detach (idle
|
|
841
|
+
// mutations; the previous test's session already drained everything it
|
|
842
|
+
// owns) and emit a fresh Meta + FullSnapshot so the new session's replay
|
|
843
|
+
// is self-contained from its first event.
|
|
844
|
+
const ATTACH_RESET_EXPR = `(function () {
|
|
845
|
+
window.__spectestRrwebEvents = [];
|
|
846
|
+
try {
|
|
847
|
+
if (typeof rrwebRecord === "function" &&
|
|
848
|
+
typeof rrwebRecord.takeFullSnapshot === "function") {
|
|
849
|
+
rrwebRecord.takeFullSnapshot();
|
|
850
|
+
}
|
|
851
|
+
} catch (e) { /* recording not active on this document */ }
|
|
852
|
+
return true;
|
|
853
|
+
})()`;
|
|
854
|
+
|
|
855
|
+
async function newHolder(
|
|
856
|
+
width: number,
|
|
857
|
+
height: number,
|
|
858
|
+
device: DevicePreset | null,
|
|
859
|
+
): Promise<ViewHolder> {
|
|
860
|
+
const { view, recordingInstalled } = await createView(width, height);
|
|
861
|
+
const holder: ViewHolder = {
|
|
862
|
+
view,
|
|
863
|
+
recordingInstalled,
|
|
864
|
+
device,
|
|
865
|
+
width,
|
|
866
|
+
height,
|
|
867
|
+
safeAreaInsets: null,
|
|
868
|
+
initScripts: [],
|
|
869
|
+
};
|
|
870
|
+
if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
|
|
871
|
+
return holder;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function attachReset(holder: ViewHolder): Promise<void> {
|
|
875
|
+
if (!holder.recordingInstalled) return;
|
|
876
|
+
try {
|
|
877
|
+
await holder.view.evaluate(ATTACH_RESET_EXPR);
|
|
878
|
+
} catch {
|
|
879
|
+
// Page mid-navigation or renderer unhappy — the first drain forces a
|
|
880
|
+
// full snapshot when one is missing (drainExpr), so replay still works.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Acquire THE persistent desktop browser (creating it on first use). There
|
|
886
|
+
* is deliberately a single one — `ctx.browser()` always returns it — so a
|
|
887
|
+
* test DAG shares one browsing session along each branch. The first call's
|
|
888
|
+
* options win; later calls attach to the existing view as-is.
|
|
889
|
+
*/
|
|
890
|
+
export async function acquirePersistentBrowser(
|
|
891
|
+
opts: BrowserOptions = {},
|
|
892
|
+
): Promise<PersistentBrowser> {
|
|
893
|
+
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
894
|
+
let attached = true;
|
|
895
|
+
if (!SHARED_BROWSER) {
|
|
896
|
+
attached = false;
|
|
897
|
+
SHARED_BROWSER = await newHolder(
|
|
898
|
+
device ? device.viewport.width : opts.width ?? 1280,
|
|
899
|
+
device ? device.viewport.height : opts.height ?? 720,
|
|
900
|
+
device,
|
|
901
|
+
);
|
|
902
|
+
} else {
|
|
903
|
+
await attachReset(SHARED_BROWSER);
|
|
904
|
+
}
|
|
905
|
+
const holder = SHARED_BROWSER;
|
|
906
|
+
const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
|
|
907
|
+
persistent: true,
|
|
908
|
+
onDestroy: () => {
|
|
909
|
+
if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
|
|
913
|
+
return { browser: backend, attached, detach };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Acquire the persistent mobile session for an app URL (one per app). A
|
|
918
|
+
* fresh session navigates to the app; an attach continues on the live page.
|
|
919
|
+
*/
|
|
920
|
+
export async function acquirePersistentMobileBackend(
|
|
921
|
+
url: string,
|
|
922
|
+
recorder: BrowserSessionRecorder | null,
|
|
923
|
+
): Promise<PersistentBrowser> {
|
|
924
|
+
const existing = SHARED_MOBILE.get(url);
|
|
925
|
+
const holder =
|
|
926
|
+
existing ??
|
|
927
|
+
(await newHolder(
|
|
928
|
+
LATEST_IPHONE.viewport.width,
|
|
929
|
+
LATEST_IPHONE.viewport.height,
|
|
930
|
+
LATEST_IPHONE,
|
|
931
|
+
));
|
|
932
|
+
if (existing) {
|
|
933
|
+
await attachReset(holder);
|
|
934
|
+
} else {
|
|
935
|
+
SHARED_MOBILE.set(url, holder);
|
|
936
|
+
}
|
|
937
|
+
const { backend, detach } = buildBackend(holder, recorder, {
|
|
938
|
+
persistent: true,
|
|
939
|
+
onDestroy: () => {
|
|
940
|
+
if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
if (!existing) await backend.navigate(url);
|
|
944
|
+
return { browser: backend, attached: existing !== undefined, detach };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** True when a navigation failed on Chromium name resolution. */
|
|
948
|
+
function isNameNotResolved(err: unknown): boolean {
|
|
949
|
+
return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Whether the daemon's own resolver can look the URL's host up. Chromium
|
|
954
|
+
* runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
|
|
955
|
+
* getaddrinfo path — a host the daemon resolves but Chrome can't means
|
|
956
|
+
* the RENDERER is broken, not the name.
|
|
957
|
+
*/
|
|
958
|
+
async function daemonResolves(url: string): Promise<boolean> {
|
|
959
|
+
try {
|
|
960
|
+
await dns.lookup(new URL(url).hostname);
|
|
961
|
+
return true;
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Replace a persistent holder's view with a freshly-spawned one in the same
|
|
969
|
+
* Chrome. Profile state — cookies, localStorage, the in-VM CA trust — is
|
|
970
|
+
* per-Chrome, so it survives; only renderer-held page state is lost, and
|
|
971
|
+
* this path only runs when that renderer already can't navigate.
|
|
972
|
+
*
|
|
973
|
+
* Known trigger: a renderer created before a snapshot fails its first
|
|
974
|
+
* post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
|
|
975
|
+
* fresh view in the SAME restored Chrome resolves fine (root cause never
|
|
976
|
+
* found — see the disabled-prewarm note at the end of /bootstrap in
|
|
977
|
+
* daemon.ts). Persistent sessions walk into exactly that scenario whenever
|
|
978
|
+
* a child test navigates, so the recovery lives here: rebuild the view,
|
|
979
|
+
* retry once.
|
|
980
|
+
*/
|
|
981
|
+
async function rebuildView(holder: ViewHolder): Promise<void> {
|
|
982
|
+
try {
|
|
983
|
+
holder.view.close();
|
|
984
|
+
} catch {
|
|
985
|
+
/* view may already be gone */
|
|
986
|
+
}
|
|
987
|
+
const fresh = await createView(holder.width, holder.height);
|
|
988
|
+
holder.view = fresh.view;
|
|
989
|
+
holder.recordingInstalled = fresh.recordingInstalled;
|
|
990
|
+
if (holder.device) {
|
|
991
|
+
holder.safeAreaInsets = await applyDeviceEmulation(holder.view, holder.device);
|
|
992
|
+
}
|
|
993
|
+
for (const source of holder.initScripts) {
|
|
994
|
+
await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
interface BackendBuildOptions {
|
|
999
|
+
/** Persistent views get the DNS-recovery navigate and a close() that
|
|
1000
|
+
* clears them out of the shared registry; ephemeral views just close. */
|
|
1001
|
+
persistent: boolean;
|
|
1002
|
+
/** Called when close() destroys the underlying view — the acquire
|
|
1003
|
+
* functions use it to drop the holder from the shared registry. */
|
|
1004
|
+
onDestroy?: () => void;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function buildBackend(
|
|
1008
|
+
holder: ViewHolder,
|
|
703
1009
|
recorder: BrowserSessionRecorder | null,
|
|
704
|
-
|
|
705
|
-
): MobileBackend {
|
|
706
|
-
|
|
1010
|
+
buildOpts: BackendBuildOptions,
|
|
1011
|
+
): { backend: MobileBackend; detach(): Promise<void> } {
|
|
1012
|
+
// All page access goes through `holder.view` — never capture the view in
|
|
1013
|
+
// a local — because the DNS-recovery rebuild swaps it mid-wrapper.
|
|
1014
|
+
// `recordingEnded` stops this wrapper's recorder writes (test end);
|
|
1015
|
+
// `viewClosed` tracks actual destruction (author called close()).
|
|
1016
|
+
let recordingEnded = false;
|
|
1017
|
+
let viewClosed = false;
|
|
707
1018
|
const sessionStart = Date.now();
|
|
708
1019
|
let stepSeq = 0;
|
|
709
1020
|
// URL observed at the previous drain. A change means the main frame
|
|
@@ -713,11 +1024,11 @@ function wrapView(
|
|
|
713
1024
|
let lastDrainUrl: string | null = null;
|
|
714
1025
|
|
|
715
1026
|
async function drain(action: BrowserAction | "close"): Promise<void> {
|
|
716
|
-
if (!recordingInstalled || !recorder ||
|
|
1027
|
+
if (!holder.recordingInstalled || !recorder || recordingEnded) return;
|
|
717
1028
|
try {
|
|
718
|
-
const urlChanged = view.url !== lastDrainUrl;
|
|
719
|
-
const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
|
|
720
|
-
lastDrainUrl = view.url;
|
|
1029
|
+
const urlChanged = holder.view.url !== lastDrainUrl;
|
|
1030
|
+
const events = await holder.view.evaluate<unknown[]>(drainExpr(urlChanged));
|
|
1031
|
+
lastDrainUrl = holder.view.url;
|
|
721
1032
|
if (Array.isArray(events) && events.length > 0) {
|
|
722
1033
|
recorder.recordStep({
|
|
723
1034
|
stepSeq: stepSeq++,
|
|
@@ -783,16 +1094,40 @@ function wrapView(
|
|
|
783
1094
|
}
|
|
784
1095
|
}
|
|
785
1096
|
|
|
786
|
-
|
|
1097
|
+
async function endRecording(): Promise<void> {
|
|
1098
|
+
if (recordingEnded) return;
|
|
1099
|
+
// Final drain before we stop writing to this recorder.
|
|
1100
|
+
await drain("close");
|
|
1101
|
+
recordingEnded = true;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const backend: MobileBackend = {
|
|
787
1105
|
get url() {
|
|
788
|
-
return view.url;
|
|
1106
|
+
return holder.view.url;
|
|
789
1107
|
},
|
|
790
1108
|
get title() {
|
|
791
|
-
return view.title;
|
|
1109
|
+
return holder.view.title;
|
|
1110
|
+
},
|
|
1111
|
+
get safeAreaInsets() {
|
|
1112
|
+
return holder.safeAreaInsets;
|
|
792
1113
|
},
|
|
793
1114
|
navigate(url) {
|
|
794
1115
|
recorder?.noteNavigation?.(url);
|
|
795
|
-
return instrumented("navigate", { url }, () =>
|
|
1116
|
+
return instrumented("navigate", { url }, async () => {
|
|
1117
|
+
try {
|
|
1118
|
+
await holder.view.navigate(url);
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
1121
|
+
// view is persistent (so it may have lived through a snapshot
|
|
1122
|
+
// restore) and the daemon itself CAN resolve the host — a name
|
|
1123
|
+
// that's genuinely unknown must fail without discarding the live
|
|
1124
|
+
// page state a rebuild would cost.
|
|
1125
|
+
if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
|
|
1126
|
+
if (!(await daemonResolves(url))) throw err;
|
|
1127
|
+
await rebuildView(holder);
|
|
1128
|
+
await holder.view.navigate(url);
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
796
1131
|
},
|
|
797
1132
|
async evaluate<T = unknown>(
|
|
798
1133
|
description: string,
|
|
@@ -809,11 +1144,29 @@ function wrapView(
|
|
|
809
1144
|
scriptTruncated: truncatedScript.truncated,
|
|
810
1145
|
},
|
|
811
1146
|
async () => {
|
|
812
|
-
const v = await view.evaluate<T>(script);
|
|
1147
|
+
const v = await holder.view.evaluate<T>(script);
|
|
813
1148
|
return v;
|
|
814
1149
|
},
|
|
815
1150
|
) as Promise<Wrapped<T>>;
|
|
816
1151
|
},
|
|
1152
|
+
addInitScript(description: string, source: string): Promise<void> {
|
|
1153
|
+
const truncated = truncateUtf8(source);
|
|
1154
|
+
return instrumented(
|
|
1155
|
+
"addInitScript",
|
|
1156
|
+
{
|
|
1157
|
+
description,
|
|
1158
|
+
script: truncated.value,
|
|
1159
|
+
scriptTruncated: truncated.truncated,
|
|
1160
|
+
},
|
|
1161
|
+
async () => {
|
|
1162
|
+
await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", {
|
|
1163
|
+
source,
|
|
1164
|
+
});
|
|
1165
|
+
// Remember it so a DNS-recovery view rebuild re-installs it.
|
|
1166
|
+
holder.initScripts.push(source);
|
|
1167
|
+
},
|
|
1168
|
+
);
|
|
1169
|
+
},
|
|
817
1170
|
async waitFor<T = unknown>(
|
|
818
1171
|
description: string,
|
|
819
1172
|
expression: string,
|
|
@@ -842,7 +1195,7 @@ function wrapView(
|
|
|
842
1195
|
fields.attempts = (fields.attempts ?? 0) + 1;
|
|
843
1196
|
let v: unknown;
|
|
844
1197
|
try {
|
|
845
|
-
v = await view.evaluate<unknown>(expression);
|
|
1198
|
+
v = await holder.view.evaluate<unknown>(expression);
|
|
846
1199
|
} catch (err) {
|
|
847
1200
|
if (Date.now() >= deadline) throw err;
|
|
848
1201
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
@@ -859,41 +1212,41 @@ function wrapView(
|
|
|
859
1212
|
}) as Promise<Wrapped<T>>;
|
|
860
1213
|
},
|
|
861
1214
|
click(selector) {
|
|
862
|
-
return instrumented("click", { selector }, () => view.click(selector));
|
|
1215
|
+
return instrumented("click", { selector }, () => holder.view.click(selector));
|
|
863
1216
|
},
|
|
864
1217
|
clickAt(x, y) {
|
|
865
|
-
return instrumented("click", { x, y }, () => view.click(x, y));
|
|
1218
|
+
return instrumented("click", { x, y }, () => holder.view.click(x, y));
|
|
866
1219
|
},
|
|
867
1220
|
type(text) {
|
|
868
1221
|
const t = truncateUtf8(text);
|
|
869
1222
|
return instrumented(
|
|
870
1223
|
"type",
|
|
871
1224
|
{ text: t.value, textTruncated: t.truncated },
|
|
872
|
-
() => view.type(text),
|
|
1225
|
+
() => holder.view.type(text),
|
|
873
1226
|
);
|
|
874
1227
|
},
|
|
875
1228
|
press(key) {
|
|
876
|
-
return instrumented("press", { key }, () => view.press(key));
|
|
1229
|
+
return instrumented("press", { key }, () => holder.view.press(key));
|
|
877
1230
|
},
|
|
878
1231
|
scroll(dx, dy) {
|
|
879
|
-
return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
|
|
1232
|
+
return instrumented("scroll", { dx, dy }, () => holder.view.scroll(dx, dy));
|
|
880
1233
|
},
|
|
881
1234
|
scrollTo(selector) {
|
|
882
|
-
return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
|
|
1235
|
+
return instrumented("scrollTo", { selector }, () => holder.view.scrollTo(selector));
|
|
883
1236
|
},
|
|
884
1237
|
back() {
|
|
885
|
-
return instrumented("back", {}, () => view.back());
|
|
1238
|
+
return instrumented("back", {}, () => holder.view.back());
|
|
886
1239
|
},
|
|
887
1240
|
forward() {
|
|
888
|
-
return instrumented("forward", {}, () => view.forward());
|
|
1241
|
+
return instrumented("forward", {}, () => holder.view.forward());
|
|
889
1242
|
},
|
|
890
1243
|
reload() {
|
|
891
|
-
return instrumented("reload", {}, () => view.reload());
|
|
1244
|
+
return instrumented("reload", {}, () => holder.view.reload());
|
|
892
1245
|
},
|
|
893
1246
|
async screenshot(options) {
|
|
894
1247
|
const format = options?.format ?? "png";
|
|
895
1248
|
return instrumented("screenshot", { format }, async () => {
|
|
896
|
-
const buf = await view.screenshot({
|
|
1249
|
+
const buf = await holder.view.screenshot({
|
|
897
1250
|
encoding: "buffer",
|
|
898
1251
|
format,
|
|
899
1252
|
quality: options?.quality,
|
|
@@ -902,12 +1255,18 @@ function wrapView(
|
|
|
902
1255
|
});
|
|
903
1256
|
},
|
|
904
1257
|
async close() {
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
1258
|
+
// Ends this wrapper's recording AND destroys the view. For a
|
|
1259
|
+
// persistent view this is the author-facing escape hatch to a fresh
|
|
1260
|
+
// browser: `onDestroy` drops the holder from the shared registry, so
|
|
1261
|
+
// the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
|
|
1262
|
+
// routine test-end path is `detach` (recording stops, view lives on
|
|
1263
|
+
// into the post-test snapshot).
|
|
1264
|
+
await endRecording();
|
|
1265
|
+
if (viewClosed) return;
|
|
1266
|
+
viewClosed = true;
|
|
1267
|
+
buildOpts.onDestroy?.();
|
|
909
1268
|
try {
|
|
910
|
-
view.close();
|
|
1269
|
+
holder.view.close();
|
|
911
1270
|
} catch {
|
|
912
1271
|
/* already closed by the runtime */
|
|
913
1272
|
}
|
|
@@ -918,11 +1277,11 @@ function wrapView(
|
|
|
918
1277
|
// mobile facade owns the author-facing "tap" vocabulary). Dispatches
|
|
919
1278
|
// a real touch so RN-Web's responder system fires.
|
|
920
1279
|
return instrumented("click", { x, y }, async () => {
|
|
921
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1280
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
922
1281
|
type: "touchStart",
|
|
923
1282
|
touchPoints: [{ x, y, id: 0 }],
|
|
924
1283
|
});
|
|
925
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1284
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
926
1285
|
type: "touchEnd",
|
|
927
1286
|
touchPoints: [],
|
|
928
1287
|
});
|
|
@@ -931,26 +1290,27 @@ function wrapView(
|
|
|
931
1290
|
swipeBy(x, y, dx, dy) {
|
|
932
1291
|
return instrumented("scroll", { dx, dy }, async () => {
|
|
933
1292
|
const steps = 8;
|
|
934
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1293
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
935
1294
|
type: "touchStart",
|
|
936
1295
|
touchPoints: [{ x, y, id: 0 }],
|
|
937
1296
|
});
|
|
938
1297
|
for (let i = 1; i <= steps; i++) {
|
|
939
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1298
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
940
1299
|
type: "touchMove",
|
|
941
1300
|
touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
|
|
942
1301
|
});
|
|
943
1302
|
}
|
|
944
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1303
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
945
1304
|
type: "touchEnd",
|
|
946
1305
|
touchPoints: [],
|
|
947
1306
|
});
|
|
948
1307
|
});
|
|
949
1308
|
},
|
|
950
1309
|
probe<T = unknown>(expression: string): Promise<T> {
|
|
951
|
-
return view.evaluate<T>(expression);
|
|
1310
|
+
return holder.view.evaluate<T>(expression);
|
|
952
1311
|
},
|
|
953
1312
|
};
|
|
1313
|
+
return { backend, detach: endRecording };
|
|
954
1314
|
}
|
|
955
1315
|
|
|
956
1316
|
interface RecordableFields {
|