@specific.dev/spectest 0.13.0 → 0.15.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 +309 -46
- package/src/components/email.ts +387 -0
- package/src/components/index.ts +15 -0
- package/src/components/k3s.ts +4 -16
- package/src/components/supabase.ts +1109 -0
- package/src/daemon.ts +392 -89
- package/src/index.ts +481 -17
- package/src/mobile.ts +23 -4
- package/src/recorder.ts +72 -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";
|
|
@@ -205,7 +212,15 @@ export interface Browser {
|
|
|
205
212
|
reload(): Promise<void>;
|
|
206
213
|
/** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
|
|
207
214
|
screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
|
|
208
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Destroy the underlying view. Idempotent; drains any pending rrweb
|
|
217
|
+
* events first. For the persistent session behind `ctx.browser()` /
|
|
218
|
+
* `ctx.mobile()` this is the escape hatch to a FRESH browser — the
|
|
219
|
+
* shared instance is discarded and the next call creates a new one.
|
|
220
|
+
* Don't call it for routine cleanup: the daemon detaches recording at
|
|
221
|
+
* test end automatically and deliberately keeps the browser alive so
|
|
222
|
+
* dependent tests inherit its state.
|
|
223
|
+
*/
|
|
209
224
|
close(): Promise<void>;
|
|
210
225
|
}
|
|
211
226
|
|
|
@@ -603,6 +618,22 @@ interface PooledView {
|
|
|
603
618
|
recordingInstalled: boolean;
|
|
604
619
|
}
|
|
605
620
|
|
|
621
|
+
/**
|
|
622
|
+
* The unit a Browser/Mobile handle drives. `view` is deliberately mutable:
|
|
623
|
+
* the DNS-recovery path (see `navigate` in {@link buildBackend}) replaces a
|
|
624
|
+
* broken restored renderer with a freshly-spawned view in the same Chrome,
|
|
625
|
+
* and every wrapper reads through the holder so the swap is transparent.
|
|
626
|
+
* `device`/`width`/`height` are kept so a rebuilt view comes back with the
|
|
627
|
+
* same viewport and emulation.
|
|
628
|
+
*/
|
|
629
|
+
interface ViewHolder {
|
|
630
|
+
view: BunWebViewInstance;
|
|
631
|
+
recordingInstalled: boolean;
|
|
632
|
+
device: DevicePreset | null;
|
|
633
|
+
width: number;
|
|
634
|
+
height: number;
|
|
635
|
+
}
|
|
636
|
+
|
|
606
637
|
// Pre-opened view pool. Renderer spawn is the expensive part of
|
|
607
638
|
// `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
|
|
608
639
|
// (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
|
|
@@ -659,6 +690,11 @@ export async function prewarmViewPool(n = 1): Promise<void> {
|
|
|
659
690
|
* Open a browser view. Always uses the Chrome backend in the daemon
|
|
660
691
|
* (Firecracker guest is Linux; WKWebView isn't available). Serves from
|
|
661
692
|
* the pre-opened pool when the caller uses the default viewport.
|
|
693
|
+
*
|
|
694
|
+
* This is the EPHEMERAL path — `close()` destroys the view. The daemon's
|
|
695
|
+
* `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
|
|
696
|
+
* / {@link acquirePersistentMobileBackend} instead, which keep one view
|
|
697
|
+
* alive across tests so it rides snapshots/forks.
|
|
662
698
|
*/
|
|
663
699
|
export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
|
|
664
700
|
return openMobileBackend(opts);
|
|
@@ -668,10 +704,9 @@ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
|
|
|
668
704
|
* Like {@link openBrowser} but returns the {@link MobileBackend} superset
|
|
669
705
|
* (touch + probe). When `opts.frame === "mobile"` the view is created at the
|
|
670
706
|
* fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
|
|
671
|
-
* device emulation applied before the first navigation.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* object.
|
|
707
|
+
* device emulation applied before the first navigation. Desktop callers go
|
|
708
|
+
* through {@link openBrowser} and get the narrower {@link Browser} view of
|
|
709
|
+
* the same object.
|
|
675
710
|
*/
|
|
676
711
|
export async function openMobileBackend(
|
|
677
712
|
opts: BrowserOptions = {},
|
|
@@ -683,27 +718,227 @@ export async function openMobileBackend(
|
|
|
683
718
|
// views, and a mobile view needs its emulation applied fresh anyway.
|
|
684
719
|
const pooled =
|
|
685
720
|
!device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
686
|
-
const
|
|
721
|
+
const base = pooled ?? (await createView(wantW, wantH));
|
|
722
|
+
const holder: ViewHolder = {
|
|
723
|
+
view: base.view,
|
|
724
|
+
recordingInstalled: base.recordingInstalled,
|
|
725
|
+
device,
|
|
726
|
+
width: wantW,
|
|
727
|
+
height: wantH,
|
|
728
|
+
};
|
|
687
729
|
|
|
688
|
-
if (device) await applyDeviceEmulation(view, device);
|
|
730
|
+
if (device) await applyDeviceEmulation(holder.view, device);
|
|
689
731
|
|
|
690
|
-
const
|
|
732
|
+
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
733
|
+
persistent: false,
|
|
734
|
+
});
|
|
691
735
|
|
|
692
736
|
// We deliberately don't forward `opts.url` to the constructor — going
|
|
693
737
|
// through our own `navigate()` keeps the recorder log uniform (one
|
|
694
738
|
// event per navigation, with timing) and drains rrweb after the load.
|
|
695
739
|
if (opts.url !== undefined) {
|
|
696
|
-
await
|
|
740
|
+
await backend.navigate(opts.url);
|
|
697
741
|
}
|
|
698
|
-
return
|
|
742
|
+
return backend;
|
|
699
743
|
}
|
|
700
744
|
|
|
701
|
-
|
|
702
|
-
|
|
745
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
746
|
+
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
747
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
748
|
+
|
|
749
|
+
// One long-lived desktop browser plus one mobile session per app URL.
|
|
750
|
+
// Module state lives in daemon memory, so it forks with the snapshot the
|
|
751
|
+
// same way fake `state` and TEST_DATA do: a test's browser — its live
|
|
752
|
+
// page, cookies, localStorage, in-memory SPA state — is captured in the
|
|
753
|
+
// post-test snapshot and inherited by `dependsOn` children, while sibling
|
|
754
|
+
// forks never see each other's sessions. That's what lets a child test
|
|
755
|
+
// continue where its parent left off (e.g. already signed in) instead of
|
|
756
|
+
// re-navigating and re-authenticating.
|
|
757
|
+
let SHARED_BROWSER: ViewHolder | null = null;
|
|
758
|
+
const SHARED_MOBILE = new Map<string, ViewHolder>();
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* What acquiring a persistent session returns. `detach` is the test-end
|
|
762
|
+
* hook (final rrweb drain, stop writing to this test's recorder, keep the
|
|
763
|
+
* view alive); `browser.close()` is the author-facing escape hatch that
|
|
764
|
+
* actually destroys the view (the next `ctx.browser()` starts fresh).
|
|
765
|
+
*/
|
|
766
|
+
export interface PersistentBrowser {
|
|
767
|
+
browser: MobileBackend;
|
|
768
|
+
/** True when this call attached to a view inherited from an earlier
|
|
769
|
+
* test (possibly across a snapshot fork) rather than creating one. */
|
|
770
|
+
attached: boolean;
|
|
771
|
+
/** Final rrweb drain + detach from the current recorder. The view stays
|
|
772
|
+
* alive so the post-test snapshot captures it. Idempotent. */
|
|
773
|
+
detach(): Promise<void>;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Runs in the page when a persistent view is attached to a new test's
|
|
777
|
+
// recorder: drop whatever rrweb buffered since the previous detach (idle
|
|
778
|
+
// mutations; the previous test's session already drained everything it
|
|
779
|
+
// owns) and emit a fresh Meta + FullSnapshot so the new session's replay
|
|
780
|
+
// is self-contained from its first event.
|
|
781
|
+
const ATTACH_RESET_EXPR = `(function () {
|
|
782
|
+
window.__spectestRrwebEvents = [];
|
|
783
|
+
try {
|
|
784
|
+
if (typeof rrwebRecord === "function" &&
|
|
785
|
+
typeof rrwebRecord.takeFullSnapshot === "function") {
|
|
786
|
+
rrwebRecord.takeFullSnapshot();
|
|
787
|
+
}
|
|
788
|
+
} catch (e) { /* recording not active on this document */ }
|
|
789
|
+
return true;
|
|
790
|
+
})()`;
|
|
791
|
+
|
|
792
|
+
async function newHolder(
|
|
793
|
+
width: number,
|
|
794
|
+
height: number,
|
|
795
|
+
device: DevicePreset | null,
|
|
796
|
+
): Promise<ViewHolder> {
|
|
797
|
+
const { view, recordingInstalled } = await createView(width, height);
|
|
798
|
+
const holder: ViewHolder = { view, recordingInstalled, device, width, height };
|
|
799
|
+
if (device) await applyDeviceEmulation(view, device);
|
|
800
|
+
return holder;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function attachReset(holder: ViewHolder): Promise<void> {
|
|
804
|
+
if (!holder.recordingInstalled) return;
|
|
805
|
+
try {
|
|
806
|
+
await holder.view.evaluate(ATTACH_RESET_EXPR);
|
|
807
|
+
} catch {
|
|
808
|
+
// Page mid-navigation or renderer unhappy — the first drain forces a
|
|
809
|
+
// full snapshot when one is missing (drainExpr), so replay still works.
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Acquire THE persistent desktop browser (creating it on first use). There
|
|
815
|
+
* is deliberately a single one — `ctx.browser()` always returns it — so a
|
|
816
|
+
* test DAG shares one browsing session along each branch. The first call's
|
|
817
|
+
* options win; later calls attach to the existing view as-is.
|
|
818
|
+
*/
|
|
819
|
+
export async function acquirePersistentBrowser(
|
|
820
|
+
opts: BrowserOptions = {},
|
|
821
|
+
): Promise<PersistentBrowser> {
|
|
822
|
+
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
823
|
+
let attached = true;
|
|
824
|
+
if (!SHARED_BROWSER) {
|
|
825
|
+
attached = false;
|
|
826
|
+
SHARED_BROWSER = await newHolder(
|
|
827
|
+
device ? device.viewport.width : opts.width ?? 1280,
|
|
828
|
+
device ? device.viewport.height : opts.height ?? 720,
|
|
829
|
+
device,
|
|
830
|
+
);
|
|
831
|
+
} else {
|
|
832
|
+
await attachReset(SHARED_BROWSER);
|
|
833
|
+
}
|
|
834
|
+
const holder = SHARED_BROWSER;
|
|
835
|
+
const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
|
|
836
|
+
persistent: true,
|
|
837
|
+
onDestroy: () => {
|
|
838
|
+
if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
|
|
842
|
+
return { browser: backend, attached, detach };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Acquire the persistent mobile session for an app URL (one per app). A
|
|
847
|
+
* fresh session navigates to the app; an attach continues on the live page.
|
|
848
|
+
*/
|
|
849
|
+
export async function acquirePersistentMobileBackend(
|
|
850
|
+
url: string,
|
|
851
|
+
recorder: BrowserSessionRecorder | null,
|
|
852
|
+
): Promise<PersistentBrowser> {
|
|
853
|
+
const existing = SHARED_MOBILE.get(url);
|
|
854
|
+
const holder =
|
|
855
|
+
existing ??
|
|
856
|
+
(await newHolder(
|
|
857
|
+
LATEST_IPHONE.viewport.width,
|
|
858
|
+
LATEST_IPHONE.viewport.height,
|
|
859
|
+
LATEST_IPHONE,
|
|
860
|
+
));
|
|
861
|
+
if (existing) {
|
|
862
|
+
await attachReset(holder);
|
|
863
|
+
} else {
|
|
864
|
+
SHARED_MOBILE.set(url, holder);
|
|
865
|
+
}
|
|
866
|
+
const { backend, detach } = buildBackend(holder, recorder, {
|
|
867
|
+
persistent: true,
|
|
868
|
+
onDestroy: () => {
|
|
869
|
+
if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
if (!existing) await backend.navigate(url);
|
|
873
|
+
return { browser: backend, attached: existing !== undefined, detach };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** True when a navigation failed on Chromium name resolution. */
|
|
877
|
+
function isNameNotResolved(err: unknown): boolean {
|
|
878
|
+
return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Whether the daemon's own resolver can look the URL's host up. Chromium
|
|
883
|
+
* runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
|
|
884
|
+
* getaddrinfo path — a host the daemon resolves but Chrome can't means
|
|
885
|
+
* the RENDERER is broken, not the name.
|
|
886
|
+
*/
|
|
887
|
+
async function daemonResolves(url: string): Promise<boolean> {
|
|
888
|
+
try {
|
|
889
|
+
await dns.lookup(new URL(url).hostname);
|
|
890
|
+
return true;
|
|
891
|
+
} catch {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Replace a persistent holder's view with a freshly-spawned one in the same
|
|
898
|
+
* Chrome. Profile state — cookies, localStorage, the in-VM CA trust — is
|
|
899
|
+
* per-Chrome, so it survives; only renderer-held page state is lost, and
|
|
900
|
+
* this path only runs when that renderer already can't navigate.
|
|
901
|
+
*
|
|
902
|
+
* Known trigger: a renderer created before a snapshot fails its first
|
|
903
|
+
* post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
|
|
904
|
+
* fresh view in the SAME restored Chrome resolves fine (root cause never
|
|
905
|
+
* found — see the disabled-prewarm note at the end of /bootstrap in
|
|
906
|
+
* daemon.ts). Persistent sessions walk into exactly that scenario whenever
|
|
907
|
+
* a child test navigates, so the recovery lives here: rebuild the view,
|
|
908
|
+
* retry once.
|
|
909
|
+
*/
|
|
910
|
+
async function rebuildView(holder: ViewHolder): Promise<void> {
|
|
911
|
+
try {
|
|
912
|
+
holder.view.close();
|
|
913
|
+
} catch {
|
|
914
|
+
/* view may already be gone */
|
|
915
|
+
}
|
|
916
|
+
const fresh = await createView(holder.width, holder.height);
|
|
917
|
+
holder.view = fresh.view;
|
|
918
|
+
holder.recordingInstalled = fresh.recordingInstalled;
|
|
919
|
+
if (holder.device) await applyDeviceEmulation(holder.view, holder.device);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
interface BackendBuildOptions {
|
|
923
|
+
/** Persistent views get the DNS-recovery navigate and a close() that
|
|
924
|
+
* clears them out of the shared registry; ephemeral views just close. */
|
|
925
|
+
persistent: boolean;
|
|
926
|
+
/** Called when close() destroys the underlying view — the acquire
|
|
927
|
+
* functions use it to drop the holder from the shared registry. */
|
|
928
|
+
onDestroy?: () => void;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function buildBackend(
|
|
932
|
+
holder: ViewHolder,
|
|
703
933
|
recorder: BrowserSessionRecorder | null,
|
|
704
|
-
|
|
705
|
-
): MobileBackend {
|
|
706
|
-
|
|
934
|
+
buildOpts: BackendBuildOptions,
|
|
935
|
+
): { backend: MobileBackend; detach(): Promise<void> } {
|
|
936
|
+
// All page access goes through `holder.view` — never capture the view in
|
|
937
|
+
// a local — because the DNS-recovery rebuild swaps it mid-wrapper.
|
|
938
|
+
// `recordingEnded` stops this wrapper's recorder writes (test end);
|
|
939
|
+
// `viewClosed` tracks actual destruction (author called close()).
|
|
940
|
+
let recordingEnded = false;
|
|
941
|
+
let viewClosed = false;
|
|
707
942
|
const sessionStart = Date.now();
|
|
708
943
|
let stepSeq = 0;
|
|
709
944
|
// URL observed at the previous drain. A change means the main frame
|
|
@@ -713,11 +948,11 @@ function wrapView(
|
|
|
713
948
|
let lastDrainUrl: string | null = null;
|
|
714
949
|
|
|
715
950
|
async function drain(action: BrowserAction | "close"): Promise<void> {
|
|
716
|
-
if (!recordingInstalled || !recorder ||
|
|
951
|
+
if (!holder.recordingInstalled || !recorder || recordingEnded) return;
|
|
717
952
|
try {
|
|
718
|
-
const urlChanged = view.url !== lastDrainUrl;
|
|
719
|
-
const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
|
|
720
|
-
lastDrainUrl = view.url;
|
|
953
|
+
const urlChanged = holder.view.url !== lastDrainUrl;
|
|
954
|
+
const events = await holder.view.evaluate<unknown[]>(drainExpr(urlChanged));
|
|
955
|
+
lastDrainUrl = holder.view.url;
|
|
721
956
|
if (Array.isArray(events) && events.length > 0) {
|
|
722
957
|
recorder.recordStep({
|
|
723
958
|
stepSeq: stepSeq++,
|
|
@@ -783,16 +1018,37 @@ function wrapView(
|
|
|
783
1018
|
}
|
|
784
1019
|
}
|
|
785
1020
|
|
|
786
|
-
|
|
1021
|
+
async function endRecording(): Promise<void> {
|
|
1022
|
+
if (recordingEnded) return;
|
|
1023
|
+
// Final drain before we stop writing to this recorder.
|
|
1024
|
+
await drain("close");
|
|
1025
|
+
recordingEnded = true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const backend: MobileBackend = {
|
|
787
1029
|
get url() {
|
|
788
|
-
return view.url;
|
|
1030
|
+
return holder.view.url;
|
|
789
1031
|
},
|
|
790
1032
|
get title() {
|
|
791
|
-
return view.title;
|
|
1033
|
+
return holder.view.title;
|
|
792
1034
|
},
|
|
793
1035
|
navigate(url) {
|
|
794
1036
|
recorder?.noteNavigation?.(url);
|
|
795
|
-
return instrumented("navigate", { url }, () =>
|
|
1037
|
+
return instrumented("navigate", { url }, async () => {
|
|
1038
|
+
try {
|
|
1039
|
+
await holder.view.navigate(url);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
1042
|
+
// view is persistent (so it may have lived through a snapshot
|
|
1043
|
+
// restore) and the daemon itself CAN resolve the host — a name
|
|
1044
|
+
// that's genuinely unknown must fail without discarding the live
|
|
1045
|
+
// page state a rebuild would cost.
|
|
1046
|
+
if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
|
|
1047
|
+
if (!(await daemonResolves(url))) throw err;
|
|
1048
|
+
await rebuildView(holder);
|
|
1049
|
+
await holder.view.navigate(url);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
796
1052
|
},
|
|
797
1053
|
async evaluate<T = unknown>(
|
|
798
1054
|
description: string,
|
|
@@ -809,7 +1065,7 @@ function wrapView(
|
|
|
809
1065
|
scriptTruncated: truncatedScript.truncated,
|
|
810
1066
|
},
|
|
811
1067
|
async () => {
|
|
812
|
-
const v = await view.evaluate<T>(script);
|
|
1068
|
+
const v = await holder.view.evaluate<T>(script);
|
|
813
1069
|
return v;
|
|
814
1070
|
},
|
|
815
1071
|
) as Promise<Wrapped<T>>;
|
|
@@ -842,7 +1098,7 @@ function wrapView(
|
|
|
842
1098
|
fields.attempts = (fields.attempts ?? 0) + 1;
|
|
843
1099
|
let v: unknown;
|
|
844
1100
|
try {
|
|
845
|
-
v = await view.evaluate<unknown>(expression);
|
|
1101
|
+
v = await holder.view.evaluate<unknown>(expression);
|
|
846
1102
|
} catch (err) {
|
|
847
1103
|
if (Date.now() >= deadline) throw err;
|
|
848
1104
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
@@ -859,41 +1115,41 @@ function wrapView(
|
|
|
859
1115
|
}) as Promise<Wrapped<T>>;
|
|
860
1116
|
},
|
|
861
1117
|
click(selector) {
|
|
862
|
-
return instrumented("click", { selector }, () => view.click(selector));
|
|
1118
|
+
return instrumented("click", { selector }, () => holder.view.click(selector));
|
|
863
1119
|
},
|
|
864
1120
|
clickAt(x, y) {
|
|
865
|
-
return instrumented("click", { x, y }, () => view.click(x, y));
|
|
1121
|
+
return instrumented("click", { x, y }, () => holder.view.click(x, y));
|
|
866
1122
|
},
|
|
867
1123
|
type(text) {
|
|
868
1124
|
const t = truncateUtf8(text);
|
|
869
1125
|
return instrumented(
|
|
870
1126
|
"type",
|
|
871
1127
|
{ text: t.value, textTruncated: t.truncated },
|
|
872
|
-
() => view.type(text),
|
|
1128
|
+
() => holder.view.type(text),
|
|
873
1129
|
);
|
|
874
1130
|
},
|
|
875
1131
|
press(key) {
|
|
876
|
-
return instrumented("press", { key }, () => view.press(key));
|
|
1132
|
+
return instrumented("press", { key }, () => holder.view.press(key));
|
|
877
1133
|
},
|
|
878
1134
|
scroll(dx, dy) {
|
|
879
|
-
return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
|
|
1135
|
+
return instrumented("scroll", { dx, dy }, () => holder.view.scroll(dx, dy));
|
|
880
1136
|
},
|
|
881
1137
|
scrollTo(selector) {
|
|
882
|
-
return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
|
|
1138
|
+
return instrumented("scrollTo", { selector }, () => holder.view.scrollTo(selector));
|
|
883
1139
|
},
|
|
884
1140
|
back() {
|
|
885
|
-
return instrumented("back", {}, () => view.back());
|
|
1141
|
+
return instrumented("back", {}, () => holder.view.back());
|
|
886
1142
|
},
|
|
887
1143
|
forward() {
|
|
888
|
-
return instrumented("forward", {}, () => view.forward());
|
|
1144
|
+
return instrumented("forward", {}, () => holder.view.forward());
|
|
889
1145
|
},
|
|
890
1146
|
reload() {
|
|
891
|
-
return instrumented("reload", {}, () => view.reload());
|
|
1147
|
+
return instrumented("reload", {}, () => holder.view.reload());
|
|
892
1148
|
},
|
|
893
1149
|
async screenshot(options) {
|
|
894
1150
|
const format = options?.format ?? "png";
|
|
895
1151
|
return instrumented("screenshot", { format }, async () => {
|
|
896
|
-
const buf = await view.screenshot({
|
|
1152
|
+
const buf = await holder.view.screenshot({
|
|
897
1153
|
encoding: "buffer",
|
|
898
1154
|
format,
|
|
899
1155
|
quality: options?.quality,
|
|
@@ -902,12 +1158,18 @@ function wrapView(
|
|
|
902
1158
|
});
|
|
903
1159
|
},
|
|
904
1160
|
async close() {
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
1161
|
+
// Ends this wrapper's recording AND destroys the view. For a
|
|
1162
|
+
// persistent view this is the author-facing escape hatch to a fresh
|
|
1163
|
+
// browser: `onDestroy` drops the holder from the shared registry, so
|
|
1164
|
+
// the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
|
|
1165
|
+
// routine test-end path is `detach` (recording stops, view lives on
|
|
1166
|
+
// into the post-test snapshot).
|
|
1167
|
+
await endRecording();
|
|
1168
|
+
if (viewClosed) return;
|
|
1169
|
+
viewClosed = true;
|
|
1170
|
+
buildOpts.onDestroy?.();
|
|
909
1171
|
try {
|
|
910
|
-
view.close();
|
|
1172
|
+
holder.view.close();
|
|
911
1173
|
} catch {
|
|
912
1174
|
/* already closed by the runtime */
|
|
913
1175
|
}
|
|
@@ -918,11 +1180,11 @@ function wrapView(
|
|
|
918
1180
|
// mobile facade owns the author-facing "tap" vocabulary). Dispatches
|
|
919
1181
|
// a real touch so RN-Web's responder system fires.
|
|
920
1182
|
return instrumented("click", { x, y }, async () => {
|
|
921
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1183
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
922
1184
|
type: "touchStart",
|
|
923
1185
|
touchPoints: [{ x, y, id: 0 }],
|
|
924
1186
|
});
|
|
925
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1187
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
926
1188
|
type: "touchEnd",
|
|
927
1189
|
touchPoints: [],
|
|
928
1190
|
});
|
|
@@ -931,26 +1193,27 @@ function wrapView(
|
|
|
931
1193
|
swipeBy(x, y, dx, dy) {
|
|
932
1194
|
return instrumented("scroll", { dx, dy }, async () => {
|
|
933
1195
|
const steps = 8;
|
|
934
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1196
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
935
1197
|
type: "touchStart",
|
|
936
1198
|
touchPoints: [{ x, y, id: 0 }],
|
|
937
1199
|
});
|
|
938
1200
|
for (let i = 1; i <= steps; i++) {
|
|
939
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1201
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
940
1202
|
type: "touchMove",
|
|
941
1203
|
touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
|
|
942
1204
|
});
|
|
943
1205
|
}
|
|
944
|
-
await view.cdp("Input.dispatchTouchEvent", {
|
|
1206
|
+
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
945
1207
|
type: "touchEnd",
|
|
946
1208
|
touchPoints: [],
|
|
947
1209
|
});
|
|
948
1210
|
});
|
|
949
1211
|
},
|
|
950
1212
|
probe<T = unknown>(expression: string): Promise<T> {
|
|
951
|
-
return view.evaluate<T>(expression);
|
|
1213
|
+
return holder.view.evaluate<T>(expression);
|
|
952
1214
|
},
|
|
953
1215
|
};
|
|
1216
|
+
return { backend, detach: endRecording };
|
|
954
1217
|
}
|
|
955
1218
|
|
|
956
1219
|
interface RecordableFields {
|