@specific.dev/spectest 0.17.0 → 0.18.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 +3 -2
- package/src/browser.ts +372 -226
- package/src/daemon.ts +17 -2
- package/src/mobile.ts +103 -129
- package/src/vendor/rrweb-plugin-console-record.umd.js +1 -0
- package/src/vendor/rrweb-record.min.js +5061 -5
package/src/browser.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// Headless browser handle for tests. Thin wrapper around
|
|
2
|
-
// driving
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Headless browser handle for tests. Thin wrapper around playwright-core
|
|
2
|
+
// driving the guest's system Chromium (pipe transport — `chromium.launch`
|
|
3
|
+
// with an explicit `executablePath`; `connectOverCDP` is off-limits, its
|
|
4
|
+
// bundled `ws` client hangs under Bun). The wrapper keeps the public
|
|
5
|
+
// surface stable (we swapped from Bun.WebView to Playwright without
|
|
6
|
+
// changing tests) and routes every call through the recorder so browser
|
|
7
|
+
// actions show up in a test's event log alongside exec/fetch/assertion
|
|
8
|
+
// events. Playwright's client state (browser/context/page objects, the
|
|
9
|
+
// launched Chromium) lives in daemon memory + the VM, so the whole pair
|
|
10
|
+
// forks with snapshots like everything else — validated 2026-07-14.
|
|
6
11
|
//
|
|
7
12
|
// On top of the per-op event recording, every Browser also captures an
|
|
8
13
|
// rrweb session: rrweb-record is injected into every document via CDP
|
|
@@ -32,52 +37,17 @@ import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
|
32
37
|
import { wrap } from "./inspect.js";
|
|
33
38
|
import type { Wrapped } from "./inspect.js";
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface BunWebViewOptions {
|
|
45
|
-
width?: number;
|
|
46
|
-
height?: number;
|
|
47
|
-
url?: string;
|
|
48
|
-
backend?: "chrome" | "webkit" | BunWebViewBackendChrome;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface BunWebViewScreenshotOptions {
|
|
52
|
-
encoding?: "buffer";
|
|
53
|
-
format?: ScreenshotFormat;
|
|
54
|
-
quality?: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface BunWebViewInstance {
|
|
58
|
-
readonly url: string;
|
|
59
|
-
readonly title: string;
|
|
60
|
-
navigate(url: string): Promise<void>;
|
|
61
|
-
evaluate<T = unknown>(script: string): Promise<T>;
|
|
62
|
-
click(selector: string): Promise<void>;
|
|
63
|
-
click(x: number, y: number): Promise<void>;
|
|
64
|
-
type(text: string): Promise<void>;
|
|
65
|
-
press(key: string): Promise<void>;
|
|
66
|
-
scroll(dx: number, dy: number): Promise<void>;
|
|
67
|
-
scrollTo(selector: string): Promise<void>;
|
|
68
|
-
back(): Promise<void>;
|
|
69
|
-
forward(): Promise<void>;
|
|
70
|
-
reload(): Promise<void>;
|
|
71
|
-
screenshot(opts?: BunWebViewScreenshotOptions): Promise<Buffer>;
|
|
72
|
-
cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
73
|
-
close(): void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface BunWebViewCtor {
|
|
77
|
-
new (options?: BunWebViewOptions): BunWebViewInstance;
|
|
78
|
-
}
|
|
40
|
+
import { chromium } from "playwright-core";
|
|
41
|
+
import type {
|
|
42
|
+
Browser as PlaywrightBrowser,
|
|
43
|
+
BrowserContext,
|
|
44
|
+
CDPSession,
|
|
45
|
+
Page,
|
|
46
|
+
} from "playwright-core";
|
|
79
47
|
|
|
80
|
-
|
|
48
|
+
// Minimal declaration of the one Bun global we use (executable lookup), so
|
|
49
|
+
// the SDK type-checks in projects that don't install `@types/bun`.
|
|
50
|
+
declare const Bun: { which(bin: string): string | null };
|
|
81
51
|
|
|
82
52
|
export interface BrowserOptions {
|
|
83
53
|
/** Viewport width in pixels. Default 1280. Ignored when `frame: "mobile"`
|
|
@@ -147,9 +117,9 @@ export interface BrowserSessionRecorder {
|
|
|
147
117
|
}
|
|
148
118
|
|
|
149
119
|
/**
|
|
150
|
-
* Headless browser handle.
|
|
151
|
-
* a
|
|
152
|
-
*
|
|
120
|
+
* Headless browser handle. Drive operations sequentially per view — the
|
|
121
|
+
* recorder assumes op-at-a-time semantics (each op's rrweb drain is
|
|
122
|
+
* attributed to it). For parallel browsing open multiple views.
|
|
153
123
|
*/
|
|
154
124
|
export interface Browser {
|
|
155
125
|
/** Last-navigated URL (updated on navigate completion). */
|
|
@@ -264,10 +234,28 @@ export interface MobileBackend extends Browser {
|
|
|
264
234
|
swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
|
|
265
235
|
/**
|
|
266
236
|
* Evaluate a JS expression in the page WITHOUT recording an event or
|
|
267
|
-
* draining rrweb
|
|
268
|
-
*
|
|
237
|
+
* draining rrweb. rrweb keeps buffering page-side; the next recorded op
|
|
238
|
+
* drains it.
|
|
269
239
|
*/
|
|
270
240
|
probe<T = unknown>(expression: string): Promise<T>;
|
|
241
|
+
/**
|
|
242
|
+
* Run `fn` against the live playwright {@link Page}, recorded as a single
|
|
243
|
+
* `action` event (with the usual rrweb drain). The locator layer's hook:
|
|
244
|
+
* one author-facing locator action = one recorded event, however many
|
|
245
|
+
* playwright calls it composes. `evaluate`/`waitFor` actions get their
|
|
246
|
+
* return value provenance-wrapped like the first-class verbs.
|
|
247
|
+
*/
|
|
248
|
+
pageOp<T>(
|
|
249
|
+
action: BrowserAction,
|
|
250
|
+
fields: Partial<RecordableFields>,
|
|
251
|
+
fn: (page: Page) => Promise<T>,
|
|
252
|
+
): Promise<T>;
|
|
253
|
+
/**
|
|
254
|
+
* Unrecorded CDP touch tap (touchStart → dwell → touchEnd). The locator
|
|
255
|
+
* layer composes it inside a {@link pageOp} so a locator `tap()` stays a
|
|
256
|
+
* single recorded event; `tapAt` is the recorded public twin.
|
|
257
|
+
*/
|
|
258
|
+
rawTap(x: number, y: number, durationMs?: number): Promise<void>;
|
|
271
259
|
}
|
|
272
260
|
|
|
273
261
|
// Default extra flags for headless Chromium inside a Firecracker microVM.
|
|
@@ -295,6 +283,75 @@ const CHROME_ARGV = [
|
|
|
295
283
|
/** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
|
|
296
284
|
const TAP_DWELL_MS = 60;
|
|
297
285
|
|
|
286
|
+
/** Default deadline for element-targeting ops (click/scrollTo and the mobile
|
|
287
|
+
* locator actions). Playwright's own default is 30 s — far too slow-failing
|
|
288
|
+
* for tests; 5 s matches the pre-Playwright behavior. Navigations keep a
|
|
289
|
+
* longer 30 s deadline (cold app servers). */
|
|
290
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
291
|
+
const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
292
|
+
|
|
293
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// Shared Chromium (playwright-core)
|
|
295
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
// One Chromium per daemon, launched lazily on first view creation and kept
|
|
298
|
+
// for the daemon's lifetime. It rides snapshots: the process, playwright's
|
|
299
|
+
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
300
|
+
// and resume in each fork (like dockerd and the daemon itself). Profile
|
|
301
|
+
// state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
|
|
302
|
+
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
303
|
+
// runs a temp --user-data-dir.
|
|
304
|
+
let PW_BROWSER: PlaywrightBrowser | null = null;
|
|
305
|
+
// The shared desktop context (default 1280×720 viewport). All desktop views
|
|
306
|
+
// live here so they share one cookie jar, mirroring the old one-Chrome-
|
|
307
|
+
// profile model. Mobile sessions and custom-viewport views get their own
|
|
308
|
+
// contexts (viewport/DPR/UA/touch are context-scoped in playwright).
|
|
309
|
+
let DESKTOP_CTX: BrowserContext | null = null;
|
|
310
|
+
|
|
311
|
+
function chromiumPath(): string {
|
|
312
|
+
return (
|
|
313
|
+
Bun.which("chromium") ??
|
|
314
|
+
Bun.which("chromium-browser") ??
|
|
315
|
+
Bun.which("google-chrome") ??
|
|
316
|
+
"/usr/bin/chromium"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
|
|
321
|
+
if (PW_BROWSER?.isConnected()) return PW_BROWSER;
|
|
322
|
+
PW_BROWSER = await chromium.launch({
|
|
323
|
+
executablePath: chromiumPath(),
|
|
324
|
+
headless: true,
|
|
325
|
+
args: CHROME_ARGV,
|
|
326
|
+
});
|
|
327
|
+
DESKTOP_CTX = null; // contexts died with the old browser (if any)
|
|
328
|
+
return PW_BROWSER;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Context options for the mobile device preset — playwright's native
|
|
332
|
+
* emulation (viewport/DPR/UA/touch are context-scoped). Safe-area insets
|
|
333
|
+
* have no context option; they stay a raw-CDP override per page. */
|
|
334
|
+
function deviceContextOptions(d: DevicePreset) {
|
|
335
|
+
return {
|
|
336
|
+
viewport: { width: d.viewport.width, height: d.viewport.height },
|
|
337
|
+
deviceScaleFactor: d.deviceScaleFactor,
|
|
338
|
+
isMobile: d.isMobile,
|
|
339
|
+
hasTouch: d.hasTouch,
|
|
340
|
+
userAgent: d.userAgent,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function ensureDesktopContext(): Promise<BrowserContext> {
|
|
345
|
+
const browser = await ensurePlaywrightBrowser();
|
|
346
|
+
if (DESKTOP_CTX) return DESKTOP_CTX;
|
|
347
|
+
DESKTOP_CTX = await browser.newContext({
|
|
348
|
+
viewport: { width: 1280, height: 720 },
|
|
349
|
+
});
|
|
350
|
+
DESKTOP_CTX.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
351
|
+
DESKTOP_CTX.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
352
|
+
return DESKTOP_CTX;
|
|
353
|
+
}
|
|
354
|
+
|
|
298
355
|
/**
|
|
299
356
|
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
300
357
|
* single EXPRESSION (it wraps the source in `await (...)`), so a statement
|
|
@@ -363,44 +420,27 @@ const LATEST_IPHONE: DevicePreset = {
|
|
|
363
420
|
safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
|
|
364
421
|
};
|
|
365
422
|
|
|
366
|
-
/** Apply
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
423
|
+
/** Apply the one device-emulation piece playwright's context options can't
|
|
424
|
+
* express: iOS safe-area insets, via raw CDP on the page's session. The
|
|
425
|
+
* override is session-global, so it persists across the app navigation
|
|
426
|
+
* that follows. Best-effort — Chromium < ~135 lacks the method.
|
|
370
427
|
*
|
|
371
|
-
* Returns the
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
async function
|
|
378
|
-
|
|
428
|
+
* Returns the insets that actually took effect (`null` when the override
|
|
429
|
+
* failed). The caller stamps them onto the session record so the dashboard
|
|
430
|
+
* can substitute the same values for `env(safe-area-inset-*)` in the
|
|
431
|
+
* replayed CSS; stamping only what was really applied keeps capture layout
|
|
432
|
+
* and replay layout in lockstep (recorded touch coordinates would
|
|
433
|
+
* misalign otherwise). */
|
|
434
|
+
async function applySafeAreaInsets(
|
|
435
|
+
cdp: CDPSession,
|
|
379
436
|
d: DevicePreset,
|
|
380
437
|
): Promise<SafeAreaInsets | null> {
|
|
381
438
|
try {
|
|
382
|
-
await
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
screenWidth: d.viewport.width,
|
|
388
|
-
screenHeight: d.viewport.height,
|
|
389
|
-
});
|
|
390
|
-
await view.cdp("Emulation.setUserAgentOverride", { userAgent: d.userAgent });
|
|
391
|
-
await view.cdp("Emulation.setTouchEmulationEnabled", {
|
|
392
|
-
enabled: d.hasTouch,
|
|
393
|
-
maxTouchPoints: 5,
|
|
394
|
-
});
|
|
395
|
-
} catch (err) {
|
|
396
|
-
// eslint-disable-next-line no-console
|
|
397
|
-
console.warn("[spectest] device emulation failed; using desktop view:", err);
|
|
398
|
-
return null;
|
|
399
|
-
}
|
|
400
|
-
try {
|
|
401
|
-
await view.cdp("Emulation.setSafeAreaInsetsOverride", {
|
|
402
|
-
insets: { ...d.safeAreaInsets },
|
|
403
|
-
});
|
|
439
|
+
await cdp.send(
|
|
440
|
+
// Not in playwright's Protocol types yet (Chromium ≥ ~135 method).
|
|
441
|
+
"Emulation.setSafeAreaInsetsOverride" as Parameters<CDPSession["send"]>[0],
|
|
442
|
+
{ insets: { ...d.safeAreaInsets } } as never,
|
|
443
|
+
);
|
|
404
444
|
return d.safeAreaInsets;
|
|
405
445
|
} catch (err) {
|
|
406
446
|
// eslint-disable-next-line no-console
|
|
@@ -413,7 +453,9 @@ async function applyDeviceEmulation(
|
|
|
413
453
|
// rrweb bootstrap
|
|
414
454
|
// ────────────────────────────────────────────────────────────────────────
|
|
415
455
|
|
|
416
|
-
// Vendored rrweb
|
|
456
|
+
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
|
|
457
|
+
// is a module object — the record function is `rrwebRecord.record` (the
|
|
458
|
+
// bootstrap resolves both this and the legacy function-shaped global). Read
|
|
417
459
|
// once at module init — the SDK ships this file in the base snapshot so
|
|
418
460
|
// no network fetch happens inside the VM.
|
|
419
461
|
const RRWEB_BUNDLE: string = (() => {
|
|
@@ -459,9 +501,18 @@ const RRWEB_CONSOLE_PLUGIN_BUNDLE: string = (() => {
|
|
|
459
501
|
// 10 KiB so longer error stacks survive without dwarfing the event stream).
|
|
460
502
|
const RRWEB_BOOTSTRAP = `
|
|
461
503
|
;(function () {
|
|
462
|
-
|
|
504
|
+
// rrweb 2.x ships \`@rrweb/record\` whose UMD global is a module object
|
|
505
|
+
// (\`rrwebRecord.record\`); the pre-2.0 bundle exposed the record function
|
|
506
|
+
// directly. Resolve either shape so the bootstrap is version-agnostic,
|
|
507
|
+
// and stash it on \`window.__spectestRec\` for the drain/attach helpers
|
|
508
|
+
// (which run as separate injected expressions).
|
|
509
|
+
var rec = (typeof rrwebRecord === "function")
|
|
510
|
+
? rrwebRecord
|
|
511
|
+
: (rrwebRecord && (rrwebRecord.record || rrwebRecord.default));
|
|
512
|
+
if (typeof rec !== "function") return;
|
|
463
513
|
if (window.__spectestRrwebInit) return;
|
|
464
514
|
window.__spectestRrwebInit = true;
|
|
515
|
+
window.__spectestRec = rec;
|
|
465
516
|
window.__spectestRrwebEvents = [];
|
|
466
517
|
var plugins = [];
|
|
467
518
|
try {
|
|
@@ -475,7 +526,7 @@ const RRWEB_BOOTSTRAP = `
|
|
|
475
526
|
}
|
|
476
527
|
} catch (e) { /* plugin init failed — keep recording DOM only. */ }
|
|
477
528
|
try {
|
|
478
|
-
|
|
529
|
+
rec({
|
|
479
530
|
emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
|
|
480
531
|
plugins: plugins,
|
|
481
532
|
// Capture page assets into the event stream so the replay renders
|
|
@@ -617,9 +668,8 @@ const RRWEB_BOOTSTRAP = `
|
|
|
617
668
|
log("injected " + ok.length + " @font-face rule(s) as data: URIs");
|
|
618
669
|
} catch (e) { log("inject FAIL " + (e && e.message)); return; }
|
|
619
670
|
try {
|
|
620
|
-
if (typeof
|
|
621
|
-
|
|
622
|
-
rrwebRecord.takeFullSnapshot();
|
|
671
|
+
if (rec && typeof rec.takeFullSnapshot === "function") {
|
|
672
|
+
rec.takeFullSnapshot();
|
|
623
673
|
log("took full snapshot");
|
|
624
674
|
}
|
|
625
675
|
} catch (e) { log("snapshot FAIL " + (e && e.message)); }
|
|
@@ -638,8 +688,14 @@ const RRWEB_BOOTSTRAP = `
|
|
|
638
688
|
})();
|
|
639
689
|
`;
|
|
640
690
|
|
|
691
|
+
// The `;` between parts is load-bearing: a vendored bundle that ends
|
|
692
|
+
// without a trailing semicolon (rrweb 2.1.0's UMD ends in `}))`) would
|
|
693
|
+
// otherwise ASI-merge with the next part's leading `(function...` into a
|
|
694
|
+
// call expression — the TypeError kills everything after the bundle, so
|
|
695
|
+
// the globals define but the bootstrap never runs (zero rrweb events,
|
|
696
|
+
// empty replays).
|
|
641
697
|
const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
642
|
-
? `${RRWEB_BUNDLE}\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n${RRWEB_BOOTSTRAP}`
|
|
698
|
+
? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
|
|
643
699
|
: "";
|
|
644
700
|
|
|
645
701
|
// Page-side expression that atomically swaps in a fresh buffer and
|
|
@@ -661,7 +717,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
|
661
717
|
//
|
|
662
718
|
// When the caller detects the document changed since the last drain
|
|
663
719
|
// (`view.url` differs) it passes `force=true`; if the outgoing buffer
|
|
664
|
-
// then lacks any FullSnapshot (type 2), we call `
|
|
720
|
+
// then lacks any FullSnapshot (type 2), we call `rec.takeFullSnapshot()`
|
|
665
721
|
// to synthesize one for the *current* DOM. That emits a fresh Meta
|
|
666
722
|
// (with the correct href) + FullSnapshot, so the step is self-contained
|
|
667
723
|
// and seeking to it shows the right page. Gating on url-change + missing
|
|
@@ -671,16 +727,16 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
|
671
727
|
function drainExpr(forceFullIfMissing: boolean): string {
|
|
672
728
|
return `(function () {
|
|
673
729
|
try {
|
|
730
|
+
var rec = window.__spectestRec;
|
|
674
731
|
if (${forceFullIfMissing ? "true" : "false"} &&
|
|
675
732
|
window.__spectestRrwebInit &&
|
|
676
|
-
typeof
|
|
677
|
-
typeof rrwebRecord.takeFullSnapshot === "function") {
|
|
733
|
+
rec && typeof rec.takeFullSnapshot === "function") {
|
|
678
734
|
var b = window.__spectestRrwebEvents || [];
|
|
679
735
|
var hasFull = false;
|
|
680
736
|
for (var i = 0; i < b.length; i++) {
|
|
681
737
|
if (b[i] && b[i].type === 2) { hasFull = true; break; }
|
|
682
738
|
}
|
|
683
|
-
if (!hasFull)
|
|
739
|
+
if (!hasFull) rec.takeFullSnapshot();
|
|
684
740
|
}
|
|
685
741
|
} catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
|
|
686
742
|
var buf = window.__spectestRrwebEvents;
|
|
@@ -694,23 +750,37 @@ function drainExpr(forceFullIfMissing: boolean): string {
|
|
|
694
750
|
// Factory
|
|
695
751
|
// ────────────────────────────────────────────────────────────────────────
|
|
696
752
|
|
|
697
|
-
/** A
|
|
698
|
-
* script installed, parked on about:blank. */
|
|
699
|
-
interface
|
|
700
|
-
|
|
753
|
+
/** A page freshly spawned into a context: renderer up, CDP session open,
|
|
754
|
+
* rrweb init script installed, parked on about:blank. */
|
|
755
|
+
interface SpawnedPage {
|
|
756
|
+
page: Page;
|
|
757
|
+
cdp: CDPSession;
|
|
701
758
|
recordingInstalled: boolean;
|
|
702
759
|
}
|
|
703
760
|
|
|
761
|
+
/** A pre-opened, never-used desktop view (lives in the shared desktop
|
|
762
|
+
* context). */
|
|
763
|
+
interface PooledView extends SpawnedPage {}
|
|
764
|
+
|
|
704
765
|
/**
|
|
705
|
-
* The unit a Browser/Mobile handle drives. `
|
|
706
|
-
* the DNS-recovery path (see `navigate` in {@link buildBackend})
|
|
707
|
-
* broken restored renderer with a freshly-spawned
|
|
708
|
-
* and every wrapper reads
|
|
709
|
-
*
|
|
710
|
-
*
|
|
766
|
+
* The unit a Browser/Mobile handle drives. `page`/`cdp` are deliberately
|
|
767
|
+
* mutable: the DNS-recovery path (see `navigate` in {@link buildBackend})
|
|
768
|
+
* replaces a broken restored renderer with a freshly-spawned page in the
|
|
769
|
+
* SAME context (cookies/localStorage survive), and every wrapper reads
|
|
770
|
+
* through the holder so the swap is transparent. `device`/`width`/`height`
|
|
771
|
+
* are kept so a rebuilt page comes back with the same emulation.
|
|
711
772
|
*/
|
|
712
773
|
interface ViewHolder {
|
|
713
|
-
view
|
|
774
|
+
/** Context this view lives in. Shared desktop context for default-size
|
|
775
|
+
* desktop views; a private one for mobile/custom-viewport views. */
|
|
776
|
+
context: BrowserContext;
|
|
777
|
+
/** Whether close() should also close the context (private contexts). */
|
|
778
|
+
ownsContext: boolean;
|
|
779
|
+
page: Page;
|
|
780
|
+
cdp: CDPSession;
|
|
781
|
+
/** Cached page title — playwright's `title()` is async but our public
|
|
782
|
+
* surface is a sync getter; refreshed after every recorded op. */
|
|
783
|
+
lastTitle: string;
|
|
714
784
|
recordingInstalled: boolean;
|
|
715
785
|
device: DevicePreset | null;
|
|
716
786
|
width: number;
|
|
@@ -720,7 +790,7 @@ interface ViewHolder {
|
|
|
720
790
|
* session record so the replay can mirror them. */
|
|
721
791
|
safeAreaInsets: SafeAreaInsets | null;
|
|
722
792
|
/** User scripts installed via `addInitScript`, kept so the DNS-recovery
|
|
723
|
-
* rebuild can re-install them on the replacement
|
|
793
|
+
* rebuild can re-install them on the replacement page. */
|
|
724
794
|
initScripts: string[];
|
|
725
795
|
}
|
|
726
796
|
|
|
@@ -736,22 +806,19 @@ interface ViewHolder {
|
|
|
736
806
|
// fake state).
|
|
737
807
|
const VIEW_POOL: PooledView[] = [];
|
|
738
808
|
|
|
739
|
-
/**
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
// Bun's docs: `cdp()` requires at least one navigate first to set up
|
|
748
|
-
// the CDP session. about:blank is the cheapest bootstrap target.
|
|
749
|
-
await view.navigate("about:blank");
|
|
809
|
+
/** Spawn a page (+ CDP session + rrweb init script) into `context` — the
|
|
810
|
+
* slow part (renderer process spawn). */
|
|
811
|
+
async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
|
|
812
|
+
const page = await context.newPage();
|
|
813
|
+
const cdp = await context.newCDPSession(page);
|
|
814
|
+
// Without Page.enable the addScriptToEvaluateOnNewDocument registration
|
|
815
|
+
// silently never fires on this session (verified in the fork spike).
|
|
816
|
+
await cdp.send("Page.enable");
|
|
750
817
|
|
|
751
818
|
let recordingInstalled = false;
|
|
752
819
|
if (PAGE_INIT_SCRIPT) {
|
|
753
820
|
try {
|
|
754
|
-
await
|
|
821
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
755
822
|
source: PAGE_INIT_SCRIPT,
|
|
756
823
|
});
|
|
757
824
|
recordingInstalled = true;
|
|
@@ -761,7 +828,36 @@ async function createView(width: number, height: number): Promise<PooledView> {
|
|
|
761
828
|
console.warn("[spectest] failed to install rrweb recorder:", err);
|
|
762
829
|
}
|
|
763
830
|
}
|
|
764
|
-
return {
|
|
831
|
+
return { page, cdp, recordingInstalled };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Acquire the context a view of this shape lives in. */
|
|
835
|
+
async function contextFor(
|
|
836
|
+
width: number,
|
|
837
|
+
height: number,
|
|
838
|
+
device: DevicePreset | null,
|
|
839
|
+
): Promise<{ context: BrowserContext; ownsContext: boolean }> {
|
|
840
|
+
if (device) {
|
|
841
|
+
const browser = await ensurePlaywrightBrowser();
|
|
842
|
+
const context = await browser.newContext(deviceContextOptions(device));
|
|
843
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
844
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
845
|
+
return { context, ownsContext: true };
|
|
846
|
+
}
|
|
847
|
+
if (width === 1280 && height === 720) {
|
|
848
|
+
return { context: await ensureDesktopContext(), ownsContext: false };
|
|
849
|
+
}
|
|
850
|
+
const browser = await ensurePlaywrightBrowser();
|
|
851
|
+
const context = await browser.newContext({ viewport: { width, height } });
|
|
852
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
853
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
854
|
+
return { context, ownsContext: true };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Create a default-desktop view for the pool. */
|
|
858
|
+
async function createView(width: number, height: number): Promise<PooledView> {
|
|
859
|
+
const { context } = await contextFor(width, height, null);
|
|
860
|
+
return spawnPage(context);
|
|
765
861
|
}
|
|
766
862
|
|
|
767
863
|
/**
|
|
@@ -777,9 +873,8 @@ export async function prewarmViewPool(n = 1): Promise<void> {
|
|
|
777
873
|
}
|
|
778
874
|
|
|
779
875
|
/**
|
|
780
|
-
* Open a browser view
|
|
781
|
-
*
|
|
782
|
-
* the pre-opened pool when the caller uses the default viewport.
|
|
876
|
+
* Open a browser view (a page in the shared Chromium). Serves from the
|
|
877
|
+
* pre-opened pool when the caller uses the default viewport.
|
|
783
878
|
*
|
|
784
879
|
* This is the EPHEMERAL path — `close()` destroys the view. The daemon's
|
|
785
880
|
* `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
|
|
@@ -805,21 +900,28 @@ export async function openMobileBackend(
|
|
|
805
900
|
const wantW = device ? device.viewport.width : opts.width ?? 1280;
|
|
806
901
|
const wantH = device ? device.viewport.height : opts.height ?? 720;
|
|
807
902
|
// Mobile views are never pooled — the pool holds only default-desktop
|
|
808
|
-
// views, and a mobile view needs its
|
|
903
|
+
// views (in the shared desktop context), and a mobile view needs its own
|
|
904
|
+
// emulated context anyway.
|
|
809
905
|
const pooled =
|
|
810
906
|
!device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
907
|
+
let holder: ViewHolder;
|
|
908
|
+
if (pooled) {
|
|
909
|
+
holder = {
|
|
910
|
+
context: await ensureDesktopContext(),
|
|
911
|
+
ownsContext: false,
|
|
912
|
+
page: pooled.page,
|
|
913
|
+
cdp: pooled.cdp,
|
|
914
|
+
lastTitle: "",
|
|
915
|
+
recordingInstalled: pooled.recordingInstalled,
|
|
916
|
+
device,
|
|
917
|
+
width: wantW,
|
|
918
|
+
height: wantH,
|
|
919
|
+
safeAreaInsets: null,
|
|
920
|
+
initScripts: [],
|
|
921
|
+
};
|
|
922
|
+
} else {
|
|
923
|
+
holder = await newHolder(wantW, wantH, device);
|
|
924
|
+
}
|
|
823
925
|
|
|
824
926
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
825
927
|
persistent: false,
|
|
@@ -849,6 +951,30 @@ export async function openMobileBackend(
|
|
|
849
951
|
let SHARED_BROWSER: ViewHolder | null = null;
|
|
850
952
|
const SHARED_MOBILE = new Map<string, ViewHolder>();
|
|
851
953
|
|
|
954
|
+
async function newHolder(
|
|
955
|
+
width: number,
|
|
956
|
+
height: number,
|
|
957
|
+
device: DevicePreset | null,
|
|
958
|
+
): Promise<ViewHolder> {
|
|
959
|
+
const { context, ownsContext } = await contextFor(width, height, device);
|
|
960
|
+
const spawned = await spawnPage(context);
|
|
961
|
+
const holder: ViewHolder = {
|
|
962
|
+
context,
|
|
963
|
+
ownsContext,
|
|
964
|
+
page: spawned.page,
|
|
965
|
+
cdp: spawned.cdp,
|
|
966
|
+
lastTitle: "",
|
|
967
|
+
recordingInstalled: spawned.recordingInstalled,
|
|
968
|
+
device,
|
|
969
|
+
width,
|
|
970
|
+
height,
|
|
971
|
+
safeAreaInsets: null,
|
|
972
|
+
initScripts: [],
|
|
973
|
+
};
|
|
974
|
+
if (device) holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, device);
|
|
975
|
+
return holder;
|
|
976
|
+
}
|
|
977
|
+
|
|
852
978
|
/**
|
|
853
979
|
* What acquiring a persistent session returns. `detach` is the test-end
|
|
854
980
|
* hook (final rrweb drain, stop writing to this test's recorder, keep the
|
|
@@ -873,37 +999,18 @@ export interface PersistentBrowser {
|
|
|
873
999
|
const ATTACH_RESET_EXPR = `(function () {
|
|
874
1000
|
window.__spectestRrwebEvents = [];
|
|
875
1001
|
try {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1002
|
+
var rec = window.__spectestRec;
|
|
1003
|
+
if (rec && typeof rec.takeFullSnapshot === "function") {
|
|
1004
|
+
rec.takeFullSnapshot();
|
|
879
1005
|
}
|
|
880
1006
|
} catch (e) { /* recording not active on this document */ }
|
|
881
1007
|
return true;
|
|
882
1008
|
})()`;
|
|
883
1009
|
|
|
884
|
-
async function newHolder(
|
|
885
|
-
width: number,
|
|
886
|
-
height: number,
|
|
887
|
-
device: DevicePreset | null,
|
|
888
|
-
): Promise<ViewHolder> {
|
|
889
|
-
const { view, recordingInstalled } = await createView(width, height);
|
|
890
|
-
const holder: ViewHolder = {
|
|
891
|
-
view,
|
|
892
|
-
recordingInstalled,
|
|
893
|
-
device,
|
|
894
|
-
width,
|
|
895
|
-
height,
|
|
896
|
-
safeAreaInsets: null,
|
|
897
|
-
initScripts: [],
|
|
898
|
-
};
|
|
899
|
-
if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
|
|
900
|
-
return holder;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
1010
|
async function attachReset(holder: ViewHolder): Promise<void> {
|
|
904
1011
|
if (!holder.recordingInstalled) return;
|
|
905
1012
|
try {
|
|
906
|
-
await holder.
|
|
1013
|
+
await holder.page.evaluate(ATTACH_RESET_EXPR);
|
|
907
1014
|
} catch {
|
|
908
1015
|
// Page mid-navigation or renderer unhappy — the first drain forces a
|
|
909
1016
|
// full snapshot when one is missing (drainExpr), so replay still works.
|
|
@@ -994,33 +1101,34 @@ async function daemonResolves(url: string): Promise<boolean> {
|
|
|
994
1101
|
}
|
|
995
1102
|
|
|
996
1103
|
/**
|
|
997
|
-
* Replace a persistent holder's
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1104
|
+
* Replace a persistent holder's page with a freshly-spawned one in the SAME
|
|
1105
|
+
* context. Context state — cookies, localStorage — survives; only
|
|
1106
|
+
* renderer-held page state is lost, and this path only runs when that
|
|
1107
|
+
* renderer already can't navigate.
|
|
1001
1108
|
*
|
|
1002
1109
|
* Known trigger: a renderer created before a snapshot fails its first
|
|
1003
1110
|
* post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
|
|
1004
|
-
* fresh
|
|
1111
|
+
* fresh page in the SAME restored Chromium resolves fine (root cause never
|
|
1005
1112
|
* found — see the disabled-prewarm note at the end of /bootstrap in
|
|
1006
1113
|
* daemon.ts). Persistent sessions walk into exactly that scenario whenever
|
|
1007
|
-
* a child test navigates, so the recovery lives here: rebuild the
|
|
1114
|
+
* a child test navigates, so the recovery lives here: rebuild the page,
|
|
1008
1115
|
* retry once.
|
|
1009
1116
|
*/
|
|
1010
1117
|
async function rebuildView(holder: ViewHolder): Promise<void> {
|
|
1011
1118
|
try {
|
|
1012
|
-
holder.
|
|
1119
|
+
await holder.page.close();
|
|
1013
1120
|
} catch {
|
|
1014
|
-
/*
|
|
1121
|
+
/* page may already be gone */
|
|
1015
1122
|
}
|
|
1016
|
-
const fresh = await
|
|
1017
|
-
holder.
|
|
1123
|
+
const fresh = await spawnPage(holder.context);
|
|
1124
|
+
holder.page = fresh.page;
|
|
1125
|
+
holder.cdp = fresh.cdp;
|
|
1018
1126
|
holder.recordingInstalled = fresh.recordingInstalled;
|
|
1019
1127
|
if (holder.device) {
|
|
1020
|
-
holder.safeAreaInsets = await
|
|
1128
|
+
holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, holder.device);
|
|
1021
1129
|
}
|
|
1022
1130
|
for (const source of holder.initScripts) {
|
|
1023
|
-
await holder.
|
|
1131
|
+
await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
1024
1132
|
}
|
|
1025
1133
|
}
|
|
1026
1134
|
|
|
@@ -1038,7 +1146,7 @@ function buildBackend(
|
|
|
1038
1146
|
recorder: BrowserSessionRecorder | null,
|
|
1039
1147
|
buildOpts: BackendBuildOptions,
|
|
1040
1148
|
): { backend: MobileBackend; detach(): Promise<void> } {
|
|
1041
|
-
// All page access goes through `holder.
|
|
1149
|
+
// All page access goes through `holder.page` — never capture the page in
|
|
1042
1150
|
// a local — because the DNS-recovery rebuild swaps it mid-wrapper.
|
|
1043
1151
|
// `recordingEnded` stops this wrapper's recorder writes (test end);
|
|
1044
1152
|
// `viewClosed` tracks actual destruction (author called close()).
|
|
@@ -1055,9 +1163,9 @@ function buildBackend(
|
|
|
1055
1163
|
async function drain(action: BrowserAction | "close"): Promise<void> {
|
|
1056
1164
|
if (!holder.recordingInstalled || !recorder || recordingEnded) return;
|
|
1057
1165
|
try {
|
|
1058
|
-
const urlChanged = holder.
|
|
1059
|
-
const events = await holder.
|
|
1060
|
-
lastDrainUrl = holder.
|
|
1166
|
+
const urlChanged = holder.page.url() !== lastDrainUrl;
|
|
1167
|
+
const events = (await holder.page.evaluate(drainExpr(urlChanged))) as unknown[];
|
|
1168
|
+
lastDrainUrl = holder.page.url();
|
|
1061
1169
|
if (Array.isArray(events) && events.length > 0) {
|
|
1062
1170
|
recorder.recordStep({
|
|
1063
1171
|
stepSeq: stepSeq++,
|
|
@@ -1082,6 +1190,12 @@ function buildBackend(
|
|
|
1082
1190
|
const resv = reserveEvent();
|
|
1083
1191
|
try {
|
|
1084
1192
|
const result = await fn();
|
|
1193
|
+
// Refresh the sync title cache (playwright's title() is async).
|
|
1194
|
+
try {
|
|
1195
|
+
holder.lastTitle = await holder.page.title();
|
|
1196
|
+
} catch {
|
|
1197
|
+
/* page mid-navigation or closed — keep the stale cache */
|
|
1198
|
+
}
|
|
1085
1199
|
// `sessionTimestamp` is the post-op wall clock — that's where we
|
|
1086
1200
|
// want the dashboard's seek to land, so clicking "type 'foo'"
|
|
1087
1201
|
// shows the input *with* the text, not the empty field just
|
|
@@ -1132,10 +1246,10 @@ function buildBackend(
|
|
|
1132
1246
|
|
|
1133
1247
|
const backend: MobileBackend = {
|
|
1134
1248
|
get url() {
|
|
1135
|
-
return holder.
|
|
1249
|
+
return holder.page.url();
|
|
1136
1250
|
},
|
|
1137
1251
|
get title() {
|
|
1138
|
-
return holder.
|
|
1252
|
+
return holder.lastTitle;
|
|
1139
1253
|
},
|
|
1140
1254
|
get safeAreaInsets() {
|
|
1141
1255
|
return holder.safeAreaInsets;
|
|
@@ -1144,7 +1258,7 @@ function buildBackend(
|
|
|
1144
1258
|
recorder?.noteNavigation?.(url);
|
|
1145
1259
|
return instrumented("navigate", { url }, async () => {
|
|
1146
1260
|
try {
|
|
1147
|
-
await holder.
|
|
1261
|
+
await holder.page.goto(url, { waitUntil: "load" });
|
|
1148
1262
|
} catch (err) {
|
|
1149
1263
|
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
1150
1264
|
// view is persistent (so it may have lived through a snapshot
|
|
@@ -1154,7 +1268,7 @@ function buildBackend(
|
|
|
1154
1268
|
if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
|
|
1155
1269
|
if (!(await daemonResolves(url))) throw err;
|
|
1156
1270
|
await rebuildView(holder);
|
|
1157
|
-
await holder.
|
|
1271
|
+
await holder.page.goto(url, { waitUntil: "load" });
|
|
1158
1272
|
}
|
|
1159
1273
|
});
|
|
1160
1274
|
},
|
|
@@ -1173,7 +1287,7 @@ function buildBackend(
|
|
|
1173
1287
|
scriptTruncated: truncatedScript.truncated,
|
|
1174
1288
|
},
|
|
1175
1289
|
async () => {
|
|
1176
|
-
const v = await holder.
|
|
1290
|
+
const v = (await holder.page.evaluate(toEvaluable(script))) as T;
|
|
1177
1291
|
return v;
|
|
1178
1292
|
},
|
|
1179
1293
|
) as Promise<Wrapped<T>>;
|
|
@@ -1188,10 +1302,12 @@ function buildBackend(
|
|
|
1188
1302
|
scriptTruncated: truncated.truncated,
|
|
1189
1303
|
},
|
|
1190
1304
|
async () => {
|
|
1191
|
-
|
|
1305
|
+
// Raw CDP (not context.addInitScript) so the script stays scoped
|
|
1306
|
+
// to THIS page — the desktop context is shared across views.
|
|
1307
|
+
await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
1192
1308
|
source,
|
|
1193
1309
|
});
|
|
1194
|
-
// Remember it so a DNS-recovery
|
|
1310
|
+
// Remember it so a DNS-recovery page rebuild re-installs it.
|
|
1195
1311
|
holder.initScripts.push(source);
|
|
1196
1312
|
},
|
|
1197
1313
|
);
|
|
@@ -1226,7 +1342,7 @@ function buildBackend(
|
|
|
1226
1342
|
fields.attempts = (fields.attempts ?? 0) + 1;
|
|
1227
1343
|
let v: unknown;
|
|
1228
1344
|
try {
|
|
1229
|
-
v = await holder.
|
|
1345
|
+
v = await holder.page.evaluate(evaluable);
|
|
1230
1346
|
} catch (err) {
|
|
1231
1347
|
if (Date.now() >= deadline) throw err;
|
|
1232
1348
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
@@ -1243,46 +1359,61 @@ function buildBackend(
|
|
|
1243
1359
|
}) as Promise<Wrapped<T>>;
|
|
1244
1360
|
},
|
|
1245
1361
|
click(selector) {
|
|
1246
|
-
return instrumented("click", { selector }, () =>
|
|
1362
|
+
return instrumented("click", { selector }, () =>
|
|
1363
|
+
holder.page.click(selector, { timeout: DEFAULT_ACTION_TIMEOUT_MS }),
|
|
1364
|
+
);
|
|
1247
1365
|
},
|
|
1248
1366
|
clickAt(x, y) {
|
|
1249
|
-
return instrumented("click", { x, y }, () => holder.
|
|
1367
|
+
return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y));
|
|
1250
1368
|
},
|
|
1251
1369
|
type(text) {
|
|
1370
|
+
// insertText path (no per-char keydown) — same semantics as before.
|
|
1252
1371
|
const t = truncateUtf8(text);
|
|
1253
1372
|
return instrumented(
|
|
1254
1373
|
"type",
|
|
1255
1374
|
{ text: t.value, textTruncated: t.truncated },
|
|
1256
|
-
() => holder.
|
|
1375
|
+
() => holder.page.keyboard.insertText(text),
|
|
1257
1376
|
);
|
|
1258
1377
|
},
|
|
1259
1378
|
press(key) {
|
|
1260
|
-
return instrumented("press", { key }, () => holder.
|
|
1379
|
+
return instrumented("press", { key }, () => holder.page.keyboard.press(key));
|
|
1261
1380
|
},
|
|
1262
1381
|
scroll(dx, dy) {
|
|
1263
|
-
return instrumented("scroll", { dx, dy }, () => holder.
|
|
1382
|
+
return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
|
|
1264
1383
|
},
|
|
1265
1384
|
scrollTo(selector) {
|
|
1266
|
-
return instrumented("scrollTo", { selector }, () =>
|
|
1385
|
+
return instrumented("scrollTo", { selector }, () =>
|
|
1386
|
+
holder.page
|
|
1387
|
+
.locator(selector)
|
|
1388
|
+
.first()
|
|
1389
|
+
.scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
|
|
1390
|
+
);
|
|
1267
1391
|
},
|
|
1268
1392
|
back() {
|
|
1269
|
-
return instrumented("back", {}, () =>
|
|
1393
|
+
return instrumented("back", {}, async () => {
|
|
1394
|
+
await holder.page.goBack();
|
|
1395
|
+
});
|
|
1270
1396
|
},
|
|
1271
1397
|
forward() {
|
|
1272
|
-
return instrumented("forward", {}, () =>
|
|
1398
|
+
return instrumented("forward", {}, async () => {
|
|
1399
|
+
await holder.page.goForward();
|
|
1400
|
+
});
|
|
1273
1401
|
},
|
|
1274
1402
|
reload() {
|
|
1275
|
-
return instrumented("reload", {}, () =>
|
|
1403
|
+
return instrumented("reload", {}, async () => {
|
|
1404
|
+
await holder.page.reload();
|
|
1405
|
+
});
|
|
1276
1406
|
},
|
|
1277
1407
|
async screenshot(options) {
|
|
1278
1408
|
const format = options?.format ?? "png";
|
|
1279
1409
|
return instrumented("screenshot", { format }, async () => {
|
|
1280
|
-
|
|
1281
|
-
|
|
1410
|
+
// Raw CDP rather than page.screenshot(): CDP supports webp too, and
|
|
1411
|
+
// captures the viewport exactly like the pre-Playwright backend.
|
|
1412
|
+
const res = (await holder.cdp.send("Page.captureScreenshot", {
|
|
1282
1413
|
format,
|
|
1283
|
-
quality: options?.quality,
|
|
1284
|
-
});
|
|
1285
|
-
return new Uint8Array(
|
|
1414
|
+
...(format === "png" ? {} : { quality: options?.quality ?? 90 }),
|
|
1415
|
+
} as never)) as { data: string };
|
|
1416
|
+
return new Uint8Array(Buffer.from(res.data, "base64"));
|
|
1286
1417
|
});
|
|
1287
1418
|
},
|
|
1288
1419
|
async close() {
|
|
@@ -1297,55 +1428,70 @@ function buildBackend(
|
|
|
1297
1428
|
viewClosed = true;
|
|
1298
1429
|
buildOpts.onDestroy?.();
|
|
1299
1430
|
try {
|
|
1300
|
-
holder.
|
|
1431
|
+
await holder.page.close();
|
|
1301
1432
|
} catch {
|
|
1302
1433
|
/* already closed by the runtime */
|
|
1303
1434
|
}
|
|
1435
|
+
if (holder.ownsContext) {
|
|
1436
|
+
try {
|
|
1437
|
+
await holder.context.close();
|
|
1438
|
+
} catch {
|
|
1439
|
+
/* context already gone (browser died) */
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1304
1442
|
},
|
|
1305
1443
|
// ── Mobile-only primitives ──────────────────────────────────────────
|
|
1444
|
+
async rawTap(x, y, durationMs) {
|
|
1445
|
+
// Dispatches a real touch so RN-Web's responder system fires.
|
|
1446
|
+
await holder.cdp.send("Input.dispatchTouchEvent", {
|
|
1447
|
+
type: "touchStart",
|
|
1448
|
+
touchPoints: [{ x, y, id: 0 }],
|
|
1449
|
+
});
|
|
1450
|
+
// Dwell between start and end, like a real finger. An instant
|
|
1451
|
+
// touchStart→touchEnd starves RN Pressables whose `onPressIn`
|
|
1452
|
+
// mutates state (optimistic label flips, scale animations): React
|
|
1453
|
+
// re-renders mid-gesture and the press never completes. The dwell
|
|
1454
|
+
// lets that commit land before release; well under any long-press
|
|
1455
|
+
// threshold (RN default 500ms).
|
|
1456
|
+
await new Promise((r) => setTimeout(r, durationMs ?? TAP_DWELL_MS));
|
|
1457
|
+
await holder.cdp.send("Input.dispatchTouchEvent", {
|
|
1458
|
+
type: "touchEnd",
|
|
1459
|
+
touchPoints: [],
|
|
1460
|
+
});
|
|
1461
|
+
},
|
|
1306
1462
|
tapAt(x, y, opts) {
|
|
1307
1463
|
// Recorded as a "click" (the event schema stays desktop-shaped; the
|
|
1308
|
-
// mobile facade owns the author-facing "tap" vocabulary).
|
|
1309
|
-
|
|
1310
|
-
return instrumented("click", { x, y }, async () => {
|
|
1311
|
-
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
1312
|
-
type: "touchStart",
|
|
1313
|
-
touchPoints: [{ x, y, id: 0 }],
|
|
1314
|
-
});
|
|
1315
|
-
// Dwell between start and end, like a real finger. An instant
|
|
1316
|
-
// touchStart→touchEnd starves RN Pressables whose `onPressIn`
|
|
1317
|
-
// mutates state (optimistic label flips, scale animations): React
|
|
1318
|
-
// re-renders mid-gesture and the press never completes. The dwell
|
|
1319
|
-
// lets that commit land before release; well under any long-press
|
|
1320
|
-
// threshold (RN default 500ms).
|
|
1321
|
-
await new Promise((r) => setTimeout(r, opts?.durationMs ?? TAP_DWELL_MS));
|
|
1322
|
-
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
1323
|
-
type: "touchEnd",
|
|
1324
|
-
touchPoints: [],
|
|
1325
|
-
});
|
|
1326
|
-
});
|
|
1464
|
+
// mobile facade owns the author-facing "tap" vocabulary).
|
|
1465
|
+
return instrumented("click", { x, y }, () => backend.rawTap(x, y, opts?.durationMs));
|
|
1327
1466
|
},
|
|
1328
1467
|
swipeBy(x, y, dx, dy) {
|
|
1329
1468
|
return instrumented("scroll", { dx, dy }, async () => {
|
|
1330
1469
|
const steps = 8;
|
|
1331
|
-
await holder.
|
|
1470
|
+
await holder.cdp.send("Input.dispatchTouchEvent", {
|
|
1332
1471
|
type: "touchStart",
|
|
1333
1472
|
touchPoints: [{ x, y, id: 0 }],
|
|
1334
1473
|
});
|
|
1335
1474
|
for (let i = 1; i <= steps; i++) {
|
|
1336
|
-
await holder.
|
|
1475
|
+
await holder.cdp.send("Input.dispatchTouchEvent", {
|
|
1337
1476
|
type: "touchMove",
|
|
1338
1477
|
touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
|
|
1339
1478
|
});
|
|
1340
1479
|
}
|
|
1341
|
-
await holder.
|
|
1480
|
+
await holder.cdp.send("Input.dispatchTouchEvent", {
|
|
1342
1481
|
type: "touchEnd",
|
|
1343
1482
|
touchPoints: [],
|
|
1344
1483
|
});
|
|
1345
1484
|
});
|
|
1346
1485
|
},
|
|
1347
1486
|
probe<T = unknown>(expression: string): Promise<T> {
|
|
1348
|
-
return holder.
|
|
1487
|
+
return holder.page.evaluate(expression) as Promise<T>;
|
|
1488
|
+
},
|
|
1489
|
+
pageOp<T>(
|
|
1490
|
+
action: BrowserAction,
|
|
1491
|
+
fields: Partial<RecordableFields>,
|
|
1492
|
+
fn: (page: Page) => Promise<T>,
|
|
1493
|
+
): Promise<T> {
|
|
1494
|
+
return instrumented(action, fields, () => fn(holder.page));
|
|
1349
1495
|
},
|
|
1350
1496
|
};
|
|
1351
1497
|
return { backend, detach: endRecording };
|