@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.
- package/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +143 -0
- package/dist/components/k3s.js +1067 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4223 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +516 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1807
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- 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
|
+
}
|