@specific.dev/spectest 0.24.0 → 0.27.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1807
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,42 @@
1
+ export interface SigV4Credentials {
2
+ accessKeyId: string;
3
+ secretAccessKey: string;
4
+ /** Session token for temporary credentials (STS/assume-role). */
5
+ sessionToken?: string;
6
+ }
7
+ export interface SigV4Scope {
8
+ region: string;
9
+ service: string;
10
+ }
11
+ /** Infer the region + service to sign for. The app's own (dummy-credentialed)
12
+ * request still carries a real credential SCOPE — that's authoritative; the
13
+ * host is a fallback. Returns `undefined` when nothing is derivable. */
14
+ export declare function inferSigV4Scope(args: {
15
+ authorization?: string | null;
16
+ credentialParam?: string | null;
17
+ host: string;
18
+ }): SigV4Scope | undefined;
19
+ export interface SignedRequest {
20
+ /** Headers to SET on the outbound request (lowercased). Includes
21
+ * `authorization`, `x-amz-date`, `x-amz-content-sha256`, and
22
+ * `x-amz-security-token` when session credentials are used. */
23
+ headers: Record<string, string>;
24
+ /** Canonical query string to place on the wire (matches the signature). */
25
+ canonicalQuery: string;
26
+ }
27
+ /** Sign an outbound request with SigV4 (header authorization). `additionalHeaders`
28
+ * (e.g. `x-amz-target`, `content-type`) are folded into the signed set — they
29
+ * must already be present on the outbound request with these exact values. */
30
+ export declare function signAwsV4Request(args: {
31
+ method: string;
32
+ /** Wire path (as the app sent it, already once-encoded). */
33
+ path: string;
34
+ /** Query params to send (SigV4 `x-amz-*` params already stripped). */
35
+ query: URLSearchParams;
36
+ host: string;
37
+ body: Uint8Array;
38
+ credentials: SigV4Credentials;
39
+ scope: SigV4Scope;
40
+ additionalHeaders?: Record<string, string>;
41
+ now?: Date;
42
+ }): SignedRequest;
@@ -0,0 +1,166 @@
1
+ // AWS Signature Version 4 signer — dependency-free, over `node:crypto`.
2
+ //
3
+ // Used by `components/replayFake.ts` to RE-SIGN a request on the record-mode
4
+ // egress forward. The app under test signs with throwaway dummy credentials
5
+ // (any AWS SDK refuses to build a request without *some* credential); the
6
+ // broker strips that dummy signature and re-signs the exact outbound request
7
+ // with the real credentials brokered eval-scoped from the platform Secrets
8
+ // store. Because signing happens at forward time over the real outbound
9
+ // canonical request, `x-amz-date` / `x-amz-content-sha256` / `authorization`
10
+ // are always internally consistent — which is exactly what static header
11
+ // injection cannot achieve for SigV4 (the signature is a keyed HMAC over the
12
+ // whole request, not a static token).
13
+ //
14
+ // Scope note: this implements the standard non-S3 canonicalization (path
15
+ // URI-encoded once more on top of the wire path; query canonicalized with
16
+ // %20). That is correct for the single-host JSON services this targets
17
+ // (DynamoDB, SQS, STS, …). S3 (single-encoded path, virtual-hosted style)
18
+ // and streaming/chunked signatures are out of scope.
19
+ import { createHash, createHmac } from "node:crypto";
20
+ function hmac(key, data) {
21
+ return createHmac("sha256", key).update(data, "utf8").digest();
22
+ }
23
+ function sha256Hex(data) {
24
+ const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
25
+ return createHash("sha256").update(buf).digest("hex");
26
+ }
27
+ /** RFC 3986 URI-encode per the SigV4 rules: unreserved chars pass through,
28
+ * everything else is `%XX` over the UTF-8 bytes. `/` is encoded only when
29
+ * `encodeSlash` (true for query components, false for path segments). */
30
+ function awsUriEncode(input, encodeSlash) {
31
+ let out = "";
32
+ for (const byte of Buffer.from(input, "utf8")) {
33
+ const isUnreserved = (byte >= 0x41 && byte <= 0x5a) || // A-Z
34
+ (byte >= 0x61 && byte <= 0x7a) || // a-z
35
+ (byte >= 0x30 && byte <= 0x39) || // 0-9
36
+ byte === 0x2d || // -
37
+ byte === 0x2e || // .
38
+ byte === 0x5f || // _
39
+ byte === 0x7e; // ~
40
+ if (isUnreserved) {
41
+ out += String.fromCharCode(byte);
42
+ }
43
+ else if (byte === 0x2f && !encodeSlash) {
44
+ out += "/";
45
+ }
46
+ else {
47
+ out += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ /** Canonical URI = the absolute path, URI-encoded once more (non-S3 rule).
53
+ * For the near-universal `/` path of JSON services this is just `/`. */
54
+ function canonicalUri(path) {
55
+ if (!path || path === "/")
56
+ return "/";
57
+ return path
58
+ .split("/")
59
+ .map((seg) => awsUriEncode(seg, false))
60
+ .join("/");
61
+ }
62
+ /** Canonical query string: each key/value AWS-URI-encoded, sorted by encoded
63
+ * key then value, joined by `&`. Also the exact string to put on the wire so
64
+ * the received query re-canonicalizes to what we signed (URLSearchParams
65
+ * would encode spaces as `+`, diverging from the signed `%20`). */
66
+ function canonicalQuery(params) {
67
+ const pairs = [];
68
+ for (const [k, v] of params)
69
+ pairs.push([awsUriEncode(k, true), awsUriEncode(v, true)]);
70
+ pairs.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0);
71
+ return pairs.map(([k, v]) => `${k}=${v}`).join("&");
72
+ }
73
+ /** `20260722T101112Z` (amzDate) + `20260722` (dateStamp) from a Date. */
74
+ function amzDates(now) {
75
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
76
+ return { amzDate, dateStamp: amzDate.slice(0, 8) };
77
+ }
78
+ /** Parse the `<AKID>/<date>/<region>/<service>/aws4_request` credential scope
79
+ * out of an `Authorization` header or an `X-Amz-Credential` query value. */
80
+ function scopeFromCredential(cred) {
81
+ if (!cred)
82
+ return undefined;
83
+ const m = /Credential=([^,\s]+)/.exec(cred);
84
+ const scope = m ? m[1] : cred;
85
+ const parts = scope.split("/");
86
+ if (parts.length >= 5 && parts[4] === "aws4_request" && parts[2] && parts[3]) {
87
+ return { region: parts[2], service: parts[3] };
88
+ }
89
+ return undefined;
90
+ }
91
+ /** Fallback: derive scope from a `<service>.<region>.amazonaws.com` host. */
92
+ function scopeFromHost(host) {
93
+ if (!host.endsWith(".amazonaws.com"))
94
+ return undefined;
95
+ const label = host.slice(0, -".amazonaws.com".length);
96
+ const parts = label.split(".");
97
+ if (parts.length === 1 && parts[0])
98
+ return { service: parts[0], region: "us-east-1" };
99
+ if (parts.length === 2 && parts[0] && parts[1])
100
+ return { service: parts[0], region: parts[1] };
101
+ // `bucket.s3.region` (S3 virtual-host) is out of scope, but recover what we can.
102
+ const s3i = parts.indexOf("s3");
103
+ if (s3i >= 0 && parts[s3i + 1])
104
+ return { service: "s3", region: parts[s3i + 1] };
105
+ return undefined;
106
+ }
107
+ /** Infer the region + service to sign for. The app's own (dummy-credentialed)
108
+ * request still carries a real credential SCOPE — that's authoritative; the
109
+ * host is a fallback. Returns `undefined` when nothing is derivable. */
110
+ export function inferSigV4Scope(args) {
111
+ return (scopeFromCredential(args.authorization) ??
112
+ scopeFromCredential(args.credentialParam) ??
113
+ scopeFromHost(args.host));
114
+ }
115
+ /** Sign an outbound request with SigV4 (header authorization). `additionalHeaders`
116
+ * (e.g. `x-amz-target`, `content-type`) are folded into the signed set — they
117
+ * must already be present on the outbound request with these exact values. */
118
+ export function signAwsV4Request(args) {
119
+ const { amzDate, dateStamp } = amzDates(args.now ?? new Date());
120
+ const payloadHash = sha256Hex(args.body);
121
+ const signedHeaders = {};
122
+ for (const [k, v] of Object.entries(args.additionalHeaders ?? {})) {
123
+ signedHeaders[k.toLowerCase()] = v.trim();
124
+ }
125
+ signedHeaders["host"] = args.host;
126
+ signedHeaders["x-amz-content-sha256"] = payloadHash;
127
+ signedHeaders["x-amz-date"] = amzDate;
128
+ if (args.credentials.sessionToken) {
129
+ signedHeaders["x-amz-security-token"] = args.credentials.sessionToken;
130
+ }
131
+ const names = Object.keys(signedHeaders).sort();
132
+ const canonicalHeaders = names.map((n) => `${n}:${signedHeaders[n]}\n`).join("");
133
+ const signedHeaderList = names.join(";");
134
+ const cq = canonicalQuery(args.query);
135
+ const canonicalRequest = [
136
+ args.method.toUpperCase(),
137
+ canonicalUri(args.path),
138
+ cq,
139
+ canonicalHeaders,
140
+ signedHeaderList,
141
+ payloadHash,
142
+ ].join("\n");
143
+ const credentialScope = `${dateStamp}/${args.scope.region}/${args.scope.service}/aws4_request`;
144
+ const stringToSign = [
145
+ "AWS4-HMAC-SHA256",
146
+ amzDate,
147
+ credentialScope,
148
+ sha256Hex(canonicalRequest),
149
+ ].join("\n");
150
+ const kDate = hmac("AWS4" + args.credentials.secretAccessKey, dateStamp);
151
+ const kRegion = hmac(kDate, args.scope.region);
152
+ const kService = hmac(kRegion, args.scope.service);
153
+ const kSigning = hmac(kService, "aws4_request");
154
+ const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
155
+ const authorization = `AWS4-HMAC-SHA256 Credential=${args.credentials.accessKeyId}/${credentialScope}, ` +
156
+ `SignedHeaders=${signedHeaderList}, Signature=${signature}`;
157
+ const headers = {
158
+ authorization,
159
+ "x-amz-date": amzDate,
160
+ "x-amz-content-sha256": payloadHash,
161
+ };
162
+ if (args.credentials.sessionToken) {
163
+ headers["x-amz-security-token"] = args.credentials.sessionToken;
164
+ }
165
+ return { headers, canonicalQuery: cq };
166
+ }
@@ -0,0 +1,314 @@
1
+ import type { Wrapped } from "./inspect.js";
2
+ import type { GetByRoleOptions, GetByTextOptions, Locator } from "./locator.js";
3
+ import type { Page } from "playwright-core";
4
+ export interface BrowserOptions {
5
+ /** Viewport width in pixels. Default 1280. Ignored when `frame: "mobile"`
6
+ * (the device preset's viewport wins). */
7
+ width?: number;
8
+ /** Viewport height in pixels. Default 720. Ignored when `frame: "mobile"`. */
9
+ height?: number;
10
+ /**
11
+ * Which device frame this session represents. `"browser"` (default) is a
12
+ * desktop viewport rendered as a browser window in the replay; `"mobile"`
13
+ * emulates a phone (viewport + DPR + mobile UA + touch via CDP) and the
14
+ * replay wraps the capture in a phone bezel. The mobile device is fixed
15
+ * (latest iPhone) and not caller-configurable — see `ctx.mobile`.
16
+ */
17
+ frame?: "browser" | "mobile";
18
+ /** Initial URL to navigate to before the constructor returns. */
19
+ url?: string;
20
+ /**
21
+ * Script installed (before the session's first navigation) to run in every
22
+ * document loaded from now on, BEFORE the document's own scripts — the
23
+ * deterministic way to plant shims (reduced-motion, `Notification`, …) that
24
+ * must beat the app bundle. Unlike installing one after the session is
25
+ * handed back, this wins the race on the FIRST document too, so no relaunch
26
+ * is needed. Rides snapshots into `dependsOn` children. For a mobile app,
27
+ * declare it once on the handle instead — `expo({ initScript })`.
28
+ */
29
+ initScript?: string;
30
+ /**
31
+ * Sink that receives rrweb event chunks. Each Browser op (navigate,
32
+ * click, …) calls `recordStep` with the events that landed in
33
+ * `window.__spectestRrwebEvents` since the last drain. The daemon
34
+ * passes a per-test, per-session sink; if `null` (e.g. tests calling
35
+ * `openBrowser` directly without a test context), rrweb still
36
+ * records page-side but the buffer is discarded on close.
37
+ */
38
+ recorder?: BrowserSessionRecorder | null;
39
+ }
40
+ /** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
41
+ export interface BrowserSessionStep {
42
+ /** Monotonic counter within the session. */
43
+ stepSeq: number;
44
+ /** Browser action that triggered this drain — same set as in the
45
+ * per-op event log, plus `"close"` for the final pre-teardown drain. */
46
+ action: BrowserAction | "close";
47
+ /** Ms since the session was opened. */
48
+ tOffsetMs: number;
49
+ /** rrweb events as emitted by `rrweb.record`'s `emit` callback. */
50
+ events: unknown[];
51
+ }
52
+ import type { BrowserAction } from "./recorder.js";
53
+ export type { BrowserAction } from "./recorder.js";
54
+ /** Sink the daemon passes in to collect per-session rrweb event chunks. */
55
+ export interface BrowserSessionRecorder {
56
+ /**
57
+ * Stable ID of this session — echoed into the per-op event log so
58
+ * the dashboard can link a browser event to its replay player.
59
+ */
60
+ readonly sessionId: string;
61
+ /** Called for each drained chunk of rrweb events. */
62
+ recordStep(step: BrowserSessionStep): void;
63
+ /** Optional: called whenever `Browser.goto(url)` is invoked. */
64
+ noteNavigation?(url: string): void;
65
+ /**
66
+ * Optional: register a captured artifact (screenshot bytes) for upload.
67
+ * Present only in eval context — the daemon wires it to the per-eval
68
+ * collector, and its absence is what makes `screenshot()` throw during
69
+ * test runs (artifacts are eval-only for now). Throws when the eval's
70
+ * artifact byte budget is exhausted.
71
+ */
72
+ registerArtifact?(artifact: {
73
+ id: string;
74
+ kind: "screenshot";
75
+ contentType: string;
76
+ sizeBytes: number;
77
+ bytesBase64: string;
78
+ }): void;
79
+ }
80
+ /** The page keyboard — Playwright's `page.keyboard`. Each method records one
81
+ * browser event. */
82
+ export interface Keyboard {
83
+ /** Press a key or chord by name (`"Enter"`, `"Tab"`, `"Control+A"`). */
84
+ press(key: string): Promise<void>;
85
+ /** Type character-by-character (fires keydown/keyup per char). */
86
+ type(text: string): Promise<void>;
87
+ /** Insert text in one shot (no per-char keydown — the paste path). */
88
+ insertText(text: string): Promise<void>;
89
+ }
90
+ /** The page mouse — Playwright's `page.mouse`. Coordinates are viewport CSS
91
+ * pixels. Prefer locators; use these only for canvas/coordinate targets. */
92
+ export interface Mouse {
93
+ click(x: number, y: number, opts?: {
94
+ button?: "left" | "right" | "middle";
95
+ clickCount?: number;
96
+ }): Promise<void>;
97
+ dblclick(x: number, y: number): Promise<void>;
98
+ move(x: number, y: number): Promise<void>;
99
+ /** Wheel-scroll by a pixel delta. */
100
+ wheel(dx: number, dy: number): Promise<void>;
101
+ }
102
+ /**
103
+ * Headless browser session — Playwright `Page`-shaped. Select elements with
104
+ * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
105
+ * drive raw input via `keyboard`/`mouse`. Operations are sequential per
106
+ * session — the recorder attributes each op's rrweb drain to it — so for
107
+ * parallel browsing open multiple sessions.
108
+ */
109
+ export interface Browser {
110
+ /** Current page URL (Playwright's synchronous `page.url()`). */
111
+ url(): string;
112
+ /** Current page `<title>` (async, like Playwright's `page.title()`). */
113
+ title(): Promise<string>;
114
+ /** Navigate to a URL; resolves when the main frame's load completes. */
115
+ goto(url: string): Promise<void>;
116
+ /** Navigate back in session history. */
117
+ goBack(): Promise<void>;
118
+ /** Navigate forward in session history. */
119
+ goForward(): Promise<void>;
120
+ /** Reload the current page. */
121
+ reload(): Promise<void>;
122
+ readonly keyboard: Keyboard;
123
+ readonly mouse: Mouse;
124
+ /** Root CSS query. */
125
+ locator(css: string): Locator;
126
+ getByRole(role: string, opts?: GetByRoleOptions): Locator;
127
+ getByText(text: string | RegExp, opts?: GetByTextOptions): Locator;
128
+ getByLabel(text: string | RegExp, opts?: GetByTextOptions): Locator;
129
+ getByPlaceholder(text: string | RegExp, opts?: GetByTextOptions): Locator;
130
+ getByAltText(text: string | RegExp, opts?: GetByTextOptions): Locator;
131
+ getByTitle(text: string | RegExp, opts?: GetByTextOptions): Locator;
132
+ getByTestId(testId: string): Locator;
133
+ /**
134
+ * Evaluate JS in the page and return the JSON-deserialised, provenance-
135
+ * wrapped result. `fn` is a real function (serialized by Playwright, with an
136
+ * optional serializable `arg`) or a string expression / statement body
137
+ * (auto-wrapped in an async IIFE, so `return`/`await` work).
138
+ *
139
+ * `description` is a short human label ("read rendered todo list") surfaced
140
+ * in the timeline so the step list isn't a wall of minified code — spectest
141
+ * keeps it (the one deviation from Playwright's bare `evaluate`).
142
+ */
143
+ evaluate<T = unknown>(description: string, fn: string | ((arg?: unknown) => T | Promise<T>), arg?: unknown): Promise<Wrapped<T>>;
144
+ /**
145
+ * Poll `fn` in the page until it returns a truthy value (Playwright's
146
+ * `page.waitForFunction`), recorded as ONE step with the total wait + poll
147
+ * count. `fn` is a function (with optional `arg`) or a string expression.
148
+ * `description` labels the step. Defaults: 5 s timeout, 100 ms polling.
149
+ */
150
+ waitForFunction<T = unknown>(description: string, fn: string | ((arg?: unknown) => T), arg?: unknown, options?: {
151
+ timeout?: number;
152
+ polling?: number;
153
+ }): Promise<Wrapped<T>>;
154
+ /**
155
+ * Capture a PNG screenshot of the viewport and upload it as a downloadable
156
+ * **artifact**. Resolves to the artifact's `art_…` id — fetch it locally
157
+ * with `spectest artifact download <id>`. Eval-only (`spectest_eval` /
158
+ * `spectest env eval`); throws with a clear message during test runs.
159
+ */
160
+ screenshot(): Promise<string>;
161
+ /**
162
+ * Destroy the underlying view. Idempotent; drains pending rrweb events
163
+ * first. For the persistent session behind `ctx.browser()`/`ctx.mobile()`
164
+ * this is the escape hatch to a FRESH browser — the shared instance is
165
+ * discarded and the next call creates a new one. Don't call it for routine
166
+ * cleanup: the daemon detaches recording at test end and keeps the browser
167
+ * alive so dependent tests inherit its state.
168
+ */
169
+ close(): Promise<void>;
170
+ }
171
+ /** The touchscreen — Playwright's `page.touchscreen`, but with the press
172
+ * dwell RN Pressables need. Mobile sessions only. */
173
+ export interface Touchscreen {
174
+ /** Touch-tap at viewport CSS coordinates. `duration` overrides the dwell. */
175
+ tap(x: number, y: number, opts?: {
176
+ duration?: number;
177
+ }): Promise<void>;
178
+ }
179
+ /**
180
+ * The internal impl type `buildBackend` returns — the {@link Browser} surface
181
+ * plus the mobile extensions and the low-level primitives the locator layer
182
+ * composes on. `ctx.browser()` exposes the narrower {@link Browser} view;
183
+ * `ctx.mobile()` the {@link import("./mobile.js").Mobile} view (adds
184
+ * `touchscreen`/`swipe`). The extras below are never in a public type.
185
+ */
186
+ export interface MobileBackend extends Browser {
187
+ /** Safe-area insets emulated on this view (`null` on desktop views or when
188
+ * the CDP override is unavailable). Stamped onto the session record. */
189
+ readonly safeAreaInsets: SafeAreaInsets | null;
190
+ readonly touchscreen: Touchscreen;
191
+ /** Swipe the screen (a touch drag from the center). Mobile extension —
192
+ * Playwright has no swipe. */
193
+ swipe(direction: "up" | "down" | "left" | "right", opts?: {
194
+ distance?: number;
195
+ }): Promise<void>;
196
+ /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
197
+ swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
198
+ /**
199
+ * Evaluate a JS expression in the page WITHOUT recording an event or
200
+ * draining rrweb. rrweb keeps buffering page-side; the next recorded op
201
+ * drains it.
202
+ */
203
+ probe<T = unknown>(expression: string): Promise<T>;
204
+ /**
205
+ * Run `fn` against the live page WITHOUT recording an event or draining
206
+ * rrweb — the poll path for `expect(locator)` matchers (one browser event
207
+ * per retry would flood the timeline).
208
+ */
209
+ silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T>;
210
+ /**
211
+ * Run `fn` against the live playwright {@link Page}, recorded as a single
212
+ * event (with the usual rrweb drain). The locator layer's hook: one
213
+ * author-facing action = one recorded event, however many playwright calls
214
+ * it composes. When `opts.wrap` the return value is provenance-wrapped
215
+ * (reads), so a later `expect(...)` nests under the step.
216
+ */
217
+ pageOp<T>(action: BrowserAction, fields: Partial<RecordableFields>, fn: (page: Page) => Promise<T>, opts?: {
218
+ wrap?: boolean;
219
+ }): Promise<T>;
220
+ /**
221
+ * Unrecorded CDP touch tap (touchStart → dwell → touchEnd). The locator
222
+ * layer composes it inside a {@link pageOp} so a locator `tap()` stays a
223
+ * single recorded event; `touchscreen.tap` is the recorded public twin.
224
+ */
225
+ rawTap(x: number, y: number, durationMs?: number): Promise<void>;
226
+ /**
227
+ * Record ONE settled browser event for an `expect(locator)` web-first
228
+ * matcher and return its seq. The matcher already read the value by polling
229
+ * {@link silentRead} (one event per retry would flood the timeline); this
230
+ * emits the single timeline step — the locator label + the session seek
231
+ * point (`sessionTimestamp`) to the settled frame — so the assertion the
232
+ * caller records next nests under it via `sourceSeq`, exactly as
233
+ * `expect(await loc.isVisible())` does. Drains rrweb like any recorded op.
234
+ * Returns `undefined` when nothing is recording.
235
+ */
236
+ recordSettled(action: BrowserAction, fields: Partial<RecordableFields>, waitedMs: number, error?: string): Promise<number | undefined>;
237
+ }
238
+ /** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
239
+ export interface SafeAreaInsets {
240
+ top: number;
241
+ right: number;
242
+ bottom: number;
243
+ left: number;
244
+ }
245
+ /**
246
+ * Pre-open `n` views into the pool (called by the daemon at the end of
247
+ * /bootstrap, before the warm-template snapshot is captured). Only
248
+ * default-viewport views are pooled — `openBrowser` with a custom
249
+ * width/height bypasses the pool.
250
+ */
251
+ export declare function prewarmViewPool(n?: number): Promise<void>;
252
+ /**
253
+ * Open a browser view (a page in the shared Chromium). Serves from the
254
+ * pre-opened pool when the caller uses the default viewport.
255
+ *
256
+ * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
257
+ * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
258
+ * / {@link acquirePersistentMobileBackend} instead, which keep one view
259
+ * alive across tests so it rides snapshots/forks.
260
+ */
261
+ export declare function openBrowser(opts?: BrowserOptions): Promise<Browser>;
262
+ /**
263
+ * Like {@link openBrowser} but returns the {@link MobileBackend} superset
264
+ * (touch + probe). When `opts.frame === "mobile"` the view is created at the
265
+ * fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
266
+ * device emulation applied before the first navigation. Desktop callers go
267
+ * through {@link openBrowser} and get the narrower {@link Browser} view of
268
+ * the same object.
269
+ */
270
+ export declare function openMobileBackend(opts?: BrowserOptions): Promise<MobileBackend>;
271
+ /**
272
+ * What acquiring a persistent session returns. `detach` is the test-end
273
+ * hook (final rrweb drain, stop writing to this test's recorder, keep the
274
+ * view alive); `browser.close()` is the author-facing escape hatch that
275
+ * actually destroys the view (the next `ctx.browser()` starts fresh).
276
+ */
277
+ export interface PersistentBrowser {
278
+ browser: MobileBackend;
279
+ /** True when this call attached to a view inherited from an earlier
280
+ * test (possibly across a snapshot fork) rather than creating one. */
281
+ attached: boolean;
282
+ /** Final rrweb drain + detach from the current recorder. The view stays
283
+ * alive so the post-test snapshot captures it. Idempotent. */
284
+ detach(): Promise<void>;
285
+ }
286
+ /**
287
+ * Acquire THE persistent desktop browser (creating it on first use). There
288
+ * is deliberately a single one — `ctx.browser()` always returns it — so a
289
+ * test DAG shares one browsing session along each branch. The first call's
290
+ * options win; later calls attach to the existing view as-is.
291
+ */
292
+ export declare function acquirePersistentBrowser(opts?: BrowserOptions): Promise<PersistentBrowser>;
293
+ /**
294
+ * Acquire the persistent mobile session for an app URL (one per app). A
295
+ * fresh session navigates to the app; an attach continues on the live page.
296
+ */
297
+ export declare function acquirePersistentMobileBackend(url: string, recorder: BrowserSessionRecorder | null, initScript?: string): Promise<PersistentBrowser>;
298
+ export interface RecordableFields {
299
+ url: string;
300
+ selector: string;
301
+ description: string;
302
+ script: string;
303
+ scriptTruncated: boolean;
304
+ text: string;
305
+ textTruncated: boolean;
306
+ key: string;
307
+ dx: number;
308
+ dy: number;
309
+ x: number;
310
+ y: number;
311
+ format: string;
312
+ attempts: number;
313
+ artifactId: string;
314
+ }