@specific.dev/spectest 0.38.0 → 0.41.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/components/k3s.js +1 -24
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +405 -946
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
package/src/locator.ts
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// Playwright-native locators for the browser/mobile test surface.
|
|
2
|
+
//
|
|
3
|
+
// A `Locator` mirrors playwright-core's `Locator` shape — the same method
|
|
4
|
+
// names, arguments, and semantics (STRICT mode included) — but it is our own
|
|
5
|
+
// type, not playwright's, so we control exactly what's exposed. Everything
|
|
6
|
+
// here is recordable in the dashboard timeline; playwright methods that bypass
|
|
7
|
+
// the recorder (`elementHandle()`, `page()`, `contentFrame()`) are
|
|
8
|
+
// deliberately absent.
|
|
9
|
+
//
|
|
10
|
+
// The implementation is a **lazy call-chain**. A locator carries a list of
|
|
11
|
+
// `Step`s (getByRole → filter → nth …) and never touches a page until an
|
|
12
|
+
// action runs. At action time `lower(page, chain)` folds the steps onto the
|
|
13
|
+
// live playwright `Page`, producing a real playwright `Locator` that is used
|
|
14
|
+
// once and discarded. This is load-bearing: the persistent browser session
|
|
15
|
+
// swaps its underlying `page` on DNS-recovery (`rebuildView` in browser.ts),
|
|
16
|
+
// so a locator must re-resolve against the *current* page every time — it can
|
|
17
|
+
// never pin a playwright `Locator`/`Page` at creation.
|
|
18
|
+
//
|
|
19
|
+
// Composition (`locator`, `getBy*`, `filter`, `and`, `or`, `first`/`last`/
|
|
20
|
+
// `nth`) returns a new lazy `Locator` with an extended chain. Terminal methods
|
|
21
|
+
// (actions, reads, `waitFor`) run inside `backend.pageOp`, so each
|
|
22
|
+
// author-facing call is exactly one recorded browser event (with its rrweb
|
|
23
|
+
// drain), whatever playwright work it composes underneath.
|
|
24
|
+
|
|
25
|
+
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
26
|
+
import type { RecordableFields } from "./browser.js";
|
|
27
|
+
import type { Wrapped } from "./inspect.js";
|
|
28
|
+
import { truncateUtf8 } from "./recorder.js";
|
|
29
|
+
|
|
30
|
+
/** Default deadline for a locator action/read's target to become actionable.
|
|
31
|
+
* Playwright's own default is 30s — far too slow-failing for tests; 5s
|
|
32
|
+
* matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
|
|
33
|
+
* `undefined` falls through to the context default (also set to this in
|
|
34
|
+
* browser.ts's `newViewContext`). Navigations keep a longer deadline. */
|
|
35
|
+
export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
// Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
|
|
38
|
+
// them (locators are never serialized) while runtime code can still detect a
|
|
39
|
+
// locator and read a nested one's chain during lowering.
|
|
40
|
+
const LOCATOR_BRAND: unique symbol = Symbol.for("spectest.locator");
|
|
41
|
+
const CHAIN: unique symbol = Symbol.for("spectest.locatorChain");
|
|
42
|
+
|
|
43
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Option shapes (re-declared here, not re-exported from playwright-core, so
|
|
45
|
+
// playwright's types stay an implementation detail of the SDK)
|
|
46
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface GetByTextOptions {
|
|
49
|
+
/** Whole-string, case-sensitive match instead of the default
|
|
50
|
+
* case-insensitive substring. Ignored when the query is a RegExp. */
|
|
51
|
+
exact?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GetByRoleOptions {
|
|
55
|
+
/** Match by accessible name. */
|
|
56
|
+
name?: string | RegExp;
|
|
57
|
+
/** Whole-string, case-sensitive name match. */
|
|
58
|
+
exact?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FilterOptions {
|
|
62
|
+
/** Keep only elements whose subtree contains this text. */
|
|
63
|
+
hasText?: string | RegExp;
|
|
64
|
+
/** Drop elements whose subtree contains this text. */
|
|
65
|
+
hasNotText?: string | RegExp;
|
|
66
|
+
/** Keep only elements that contain a match for this locator. */
|
|
67
|
+
has?: Locator;
|
|
68
|
+
/** Drop elements that contain a match for this locator. */
|
|
69
|
+
hasNot?: Locator;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ClickOptions {
|
|
73
|
+
timeout?: number;
|
|
74
|
+
force?: boolean;
|
|
75
|
+
button?: "left" | "right" | "middle";
|
|
76
|
+
clickCount?: number;
|
|
77
|
+
delay?: number;
|
|
78
|
+
position?: { x: number; y: number };
|
|
79
|
+
modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TimeoutOption {
|
|
83
|
+
timeout?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BoundingBox {
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type WaitForState = "attached" | "detached" | "visible" | "hidden";
|
|
94
|
+
|
|
95
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Chain model
|
|
97
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
type Step =
|
|
100
|
+
| { m: "locator"; args: [string] }
|
|
101
|
+
| { m: "getByRole"; args: [string, GetByRoleOptions?] }
|
|
102
|
+
| { m: "getByText"; args: [string | RegExp, GetByTextOptions?] }
|
|
103
|
+
| { m: "getByLabel"; args: [string | RegExp, GetByTextOptions?] }
|
|
104
|
+
| { m: "getByPlaceholder"; args: [string | RegExp, GetByTextOptions?] }
|
|
105
|
+
| { m: "getByAltText"; args: [string | RegExp, GetByTextOptions?] }
|
|
106
|
+
| { m: "getByTitle"; args: [string | RegExp, GetByTextOptions?] }
|
|
107
|
+
| { m: "getByTestId"; args: [string] }
|
|
108
|
+
| { m: "filter"; args: [FilterOptions] }
|
|
109
|
+
| { m: "and"; args: [Locator] }
|
|
110
|
+
| { m: "or"; args: [Locator] }
|
|
111
|
+
| { m: "first"; args: [] }
|
|
112
|
+
| { m: "last"; args: [] }
|
|
113
|
+
| { m: "nth"; args: [number] };
|
|
114
|
+
|
|
115
|
+
interface Chain {
|
|
116
|
+
steps: Step[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface InternalLocator extends Locator {
|
|
120
|
+
readonly [LOCATOR_BRAND]: true;
|
|
121
|
+
readonly [CHAIN]: Chain;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True if `x` is a spectest {@link Locator}. */
|
|
125
|
+
export function isLocator(x: unknown): x is Locator {
|
|
126
|
+
return (
|
|
127
|
+
typeof x === "object" &&
|
|
128
|
+
x !== null &&
|
|
129
|
+
(x as Record<symbol, unknown>)[LOCATOR_BRAND] === true
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function chainOf(loc: Locator): Chain {
|
|
134
|
+
return (loc as InternalLocator)[CHAIN];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// Lowering: chain → live playwright Locator
|
|
139
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Fold a chain onto a live page, producing a throwaway playwright Locator.
|
|
142
|
+
* Nested spectest locators (`filter({has})`, `and`, `or`) are lowered
|
|
143
|
+
* recursively against the same page. */
|
|
144
|
+
export function lower(page: Page, chain: Chain): PWLocator {
|
|
145
|
+
let cur: Page | PWLocator = page;
|
|
146
|
+
for (const step of chain.steps) {
|
|
147
|
+
cur = applyStep(page, cur, step);
|
|
148
|
+
}
|
|
149
|
+
return cur as PWLocator;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyStep(page: Page, cur: Page | PWLocator, step: Step): PWLocator {
|
|
153
|
+
switch (step.m) {
|
|
154
|
+
case "locator":
|
|
155
|
+
return cur.locator(step.args[0]);
|
|
156
|
+
case "getByRole":
|
|
157
|
+
return cur.getByRole(
|
|
158
|
+
step.args[0] as Parameters<Page["getByRole"]>[0],
|
|
159
|
+
step.args[1],
|
|
160
|
+
);
|
|
161
|
+
case "getByText":
|
|
162
|
+
return cur.getByText(step.args[0], step.args[1]);
|
|
163
|
+
case "getByLabel":
|
|
164
|
+
return cur.getByLabel(step.args[0], step.args[1]);
|
|
165
|
+
case "getByPlaceholder":
|
|
166
|
+
return cur.getByPlaceholder(step.args[0], step.args[1]);
|
|
167
|
+
case "getByAltText":
|
|
168
|
+
return cur.getByAltText(step.args[0], step.args[1]);
|
|
169
|
+
case "getByTitle":
|
|
170
|
+
return cur.getByTitle(step.args[0], step.args[1]);
|
|
171
|
+
case "getByTestId":
|
|
172
|
+
return cur.getByTestId(step.args[0]);
|
|
173
|
+
case "filter": {
|
|
174
|
+
const o = step.args[0];
|
|
175
|
+
return (cur as PWLocator).filter({
|
|
176
|
+
hasText: o.hasText,
|
|
177
|
+
hasNotText: o.hasNotText,
|
|
178
|
+
has: o.has ? lower(page, chainOf(o.has)) : undefined,
|
|
179
|
+
hasNot: o.hasNot ? lower(page, chainOf(o.hasNot)) : undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
case "and":
|
|
183
|
+
return (cur as PWLocator).and(lower(page, chainOf(step.args[0])));
|
|
184
|
+
case "or":
|
|
185
|
+
return (cur as PWLocator).or(lower(page, chainOf(step.args[0])));
|
|
186
|
+
case "first":
|
|
187
|
+
return (cur as PWLocator).first();
|
|
188
|
+
case "last":
|
|
189
|
+
return (cur as PWLocator).last();
|
|
190
|
+
case "nth":
|
|
191
|
+
return (cur as PWLocator).nth(step.args[0]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
196
|
+
// Human labels (event `selector` field / dashboard timeline)
|
|
197
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function textArg(v: string | RegExp): string {
|
|
200
|
+
return v instanceof RegExp ? `/${v.source}/${v.flags}` : JSON.stringify(v);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stepLabel(step: Step): string {
|
|
204
|
+
switch (step.m) {
|
|
205
|
+
case "locator":
|
|
206
|
+
return `css ${JSON.stringify(step.args[0])}`;
|
|
207
|
+
case "getByRole": {
|
|
208
|
+
const name = step.args[1]?.name;
|
|
209
|
+
return name !== undefined
|
|
210
|
+
? `role ${step.args[0]} ${textArg(name)}`
|
|
211
|
+
: `role ${step.args[0]}`;
|
|
212
|
+
}
|
|
213
|
+
case "getByText":
|
|
214
|
+
return `text ${textArg(step.args[0])}`;
|
|
215
|
+
case "getByLabel":
|
|
216
|
+
return `label ${textArg(step.args[0])}`;
|
|
217
|
+
case "getByPlaceholder":
|
|
218
|
+
return `placeholder ${textArg(step.args[0])}`;
|
|
219
|
+
case "getByAltText":
|
|
220
|
+
return `alt ${textArg(step.args[0])}`;
|
|
221
|
+
case "getByTitle":
|
|
222
|
+
return `title ${textArg(step.args[0])}`;
|
|
223
|
+
case "getByTestId":
|
|
224
|
+
return `testid ${JSON.stringify(step.args[0])}`;
|
|
225
|
+
case "filter": {
|
|
226
|
+
const o = step.args[0];
|
|
227
|
+
if (o.has) return `filter(has: ${chainLabel(chainOf(o.has))})`;
|
|
228
|
+
if (o.hasNot) return `filter(hasNot: ${chainLabel(chainOf(o.hasNot))})`;
|
|
229
|
+
if (o.hasText !== undefined) return `filter(hasText: ${textArg(o.hasText)})`;
|
|
230
|
+
if (o.hasNotText !== undefined) return `filter(hasNotText: ${textArg(o.hasNotText)})`;
|
|
231
|
+
return "filter()";
|
|
232
|
+
}
|
|
233
|
+
case "and":
|
|
234
|
+
return `and(${chainLabel(chainOf(step.args[0]))})`;
|
|
235
|
+
case "or":
|
|
236
|
+
return `or(${chainLabel(chainOf(step.args[0]))})`;
|
|
237
|
+
case "first":
|
|
238
|
+
return ".first()";
|
|
239
|
+
case "last":
|
|
240
|
+
return ".last()";
|
|
241
|
+
case "nth":
|
|
242
|
+
return `.nth(${step.args[0]})`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Short human label for a chain, e.g.
|
|
247
|
+
* `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
|
|
248
|
+
* Root first; narrowing steps appended in reading order. */
|
|
249
|
+
export function chainLabel(chain: Chain): string {
|
|
250
|
+
const [root, ...rest] = chain.steps;
|
|
251
|
+
if (!root) return "<empty>";
|
|
252
|
+
let out = stepLabel(root);
|
|
253
|
+
for (const s of rest) {
|
|
254
|
+
// first/last/nth read naturally appended ("… .first()"); the rest are
|
|
255
|
+
// narrowing relations, joined with a left-arrow.
|
|
256
|
+
out += s.m === "first" || s.m === "last" || s.m === "nth"
|
|
257
|
+
? ` ${stepLabel(s)}`
|
|
258
|
+
: ` ‹ ${stepLabel(s)}`;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
264
|
+
// Backend + strategy seams
|
|
265
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** The slice of the browser backend a locator needs. `pageOp` records one
|
|
268
|
+
* browser event and (when `wrap`) provenance-wraps the result so a later
|
|
269
|
+
* `expect(...)` nests under the step. `silentRead` runs a lowered read with
|
|
270
|
+
* NO recording/drain — the poll path for `expect(locator)` matchers, which
|
|
271
|
+
* must not emit one browser event per retry. */
|
|
272
|
+
export interface LocatorBackend {
|
|
273
|
+
pageOp<T>(
|
|
274
|
+
action: string,
|
|
275
|
+
fields: Partial<RecordableFields>,
|
|
276
|
+
fn: (page: Page) => Promise<T>,
|
|
277
|
+
opts?: { wrap?: boolean },
|
|
278
|
+
): Promise<T>;
|
|
279
|
+
silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T>;
|
|
280
|
+
/** CDP touch tap with press dwell (mobile only). */
|
|
281
|
+
rawTap(x: number, y: number, durationMs?: number): Promise<void>;
|
|
282
|
+
/** Record ONE settled browser event for an `expect(locator)` matcher (label
|
|
283
|
+
* + session seek point) and return its seq, so the assertion nests under it.
|
|
284
|
+
* See {@link "./browser".MobileBackend.recordSettled}. */
|
|
285
|
+
recordSettled(
|
|
286
|
+
action: string,
|
|
287
|
+
fields: Partial<RecordableFields>,
|
|
288
|
+
waitedMs: number,
|
|
289
|
+
error?: string,
|
|
290
|
+
): Promise<number | undefined>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
|
|
294
|
+
* poll. See {@link getLocatorProbe}. */
|
|
295
|
+
export interface LocatorProbe {
|
|
296
|
+
/** Human label of the locator (for failure messages). */
|
|
297
|
+
readonly label: string;
|
|
298
|
+
isVisible(): Promise<boolean>;
|
|
299
|
+
textContent(timeout?: number): Promise<string | null>;
|
|
300
|
+
inputValue(timeout?: number): Promise<string>;
|
|
301
|
+
count(): Promise<number>;
|
|
302
|
+
isEnabled(timeout?: number): Promise<boolean>;
|
|
303
|
+
isChecked(timeout?: number): Promise<boolean>;
|
|
304
|
+
/** After the silent poll settles, emit the single settled browser step this
|
|
305
|
+
* locator's `expect(...)` matcher assertion nests under, and return its seq
|
|
306
|
+
* (provenance + replay seek). `action` is the matcher name, `waitedMs` the
|
|
307
|
+
* poll time, `error` marks the step failed on a timed-out matcher. Returns
|
|
308
|
+
* `undefined` when nothing is recording. */
|
|
309
|
+
settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
|
|
313
|
+
* session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
|
|
314
|
+
* can reach both seams without importing `browser.ts` (and with it
|
|
315
|
+
* playwright) at runtime. */
|
|
316
|
+
export interface BrowserProbe {
|
|
317
|
+
/** Current main-frame URL. Synchronous, unrecorded, no rrweb drain. */
|
|
318
|
+
url(): string;
|
|
319
|
+
/** After the silent poll settles, emit the single settled browser step the
|
|
320
|
+
* matcher's assertion nests under, and return its seq. Same contract as
|
|
321
|
+
* {@link LocatorProbe.settle}. */
|
|
322
|
+
settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const PROBE: unique symbol = Symbol.for("spectest.locatorProbe");
|
|
326
|
+
const BROWSER_PROBE: unique symbol = Symbol.for("spectest.browserProbe");
|
|
327
|
+
|
|
328
|
+
/** Brand a browser/mobile session with its {@link BrowserProbe}. */
|
|
329
|
+
export function attachBrowserProbe<T extends object>(session: T, probe: BrowserProbe): T {
|
|
330
|
+
(session as unknown as Record<symbol, BrowserProbe>)[BROWSER_PROBE] = probe;
|
|
331
|
+
return session;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** True if `x` is a spectest browser/mobile session (i.e. probe-branded). */
|
|
335
|
+
export function isBrowserSession(x: unknown): boolean {
|
|
336
|
+
return typeof x === "object" && x !== null && BROWSER_PROBE in x;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The probe branded onto a browser/mobile session by `buildBackend`. */
|
|
340
|
+
export function getBrowserProbe(session: unknown): BrowserProbe {
|
|
341
|
+
return (session as Record<symbol, BrowserProbe>)[BROWSER_PROBE];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The silent-read probe for a locator — the seam `expect(locator)` matchers
|
|
345
|
+
* poll (in index.ts) without pulling playwright types or the backend into
|
|
346
|
+
* that module. */
|
|
347
|
+
export function getLocatorProbe(loc: Locator): LocatorProbe {
|
|
348
|
+
return (loc as unknown as Record<symbol, LocatorProbe>)[PROBE];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Per-session flavor of the `tap()` gesture. Desktop and mobile differ only
|
|
352
|
+
* here; every other locator method is platform-agnostic. */
|
|
353
|
+
export interface ActionStrategy {
|
|
354
|
+
/** Whether this session supports touch. Desktop → `tap()` throws. */
|
|
355
|
+
readonly touch: boolean;
|
|
356
|
+
/** Perform a touch tap on the resolved element (mobile only). `rec` is the
|
|
357
|
+
* gesture's own step, still open — see {@link stampActionPoint}. */
|
|
358
|
+
tap(
|
|
359
|
+
backend: LocatorBackend,
|
|
360
|
+
loc: PWLocator,
|
|
361
|
+
opts?: { timeout?: number; duration?: number },
|
|
362
|
+
rec?: Partial<RecordableFields>,
|
|
363
|
+
): Promise<void>;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Desktop: no touchscreen. `tap()` is a mobile gesture — steer authors to
|
|
367
|
+
* `click()`. */
|
|
368
|
+
export const desktopStrategy: ActionStrategy = {
|
|
369
|
+
touch: false,
|
|
370
|
+
async tap() {
|
|
371
|
+
throw new Error(
|
|
372
|
+
"tap() is a mobile-session gesture (ctx.mobile). On a desktop ctx.browser() session use click().",
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Mobile: resolve the element's center and dispatch a real CDP touch with
|
|
378
|
+
* the press dwell RN Pressables need (playwright's `tap()` has no dwell). */
|
|
379
|
+
export const mobileStrategy: ActionStrategy = {
|
|
380
|
+
touch: true,
|
|
381
|
+
async tap(backend, loc, opts, rec) {
|
|
382
|
+
await loc.waitFor({ state: "visible", timeout: opts?.timeout });
|
|
383
|
+
await loc.scrollIntoViewIfNeeded({ timeout: opts?.timeout });
|
|
384
|
+
const box = await loc.boundingBox();
|
|
385
|
+
if (!box) throw new Error("tap: element vanished before it could be tapped");
|
|
386
|
+
const x = box.x + box.width / 2;
|
|
387
|
+
const y = box.y + box.height / 2;
|
|
388
|
+
// Exact, no probe needed: this IS the point the touch is dispatched at,
|
|
389
|
+
// measured after the scroll. Same field the replay reads — see
|
|
390
|
+
// stampActionPoint.
|
|
391
|
+
if (rec) {
|
|
392
|
+
rec.x = Math.round(x);
|
|
393
|
+
rec.y = Math.round(y);
|
|
394
|
+
}
|
|
395
|
+
await backend.rawTap(x, y, opts?.duration);
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** How long the click-point probe waits for the element. Short on purpose:
|
|
400
|
+
* either it is already on screen and the answer is one round trip away, or
|
|
401
|
+
* the action's own actionability wait — with the author's timeout and a real
|
|
402
|
+
* error message — is the one that should report it. */
|
|
403
|
+
const POINT_PROBE_MS = 250;
|
|
404
|
+
|
|
405
|
+
/** Stamp the viewport point an action is about to act on onto its (still
|
|
406
|
+
* open) step, as the `x`/`y` a raw `browser.clickAt` records.
|
|
407
|
+
*
|
|
408
|
+
* The replay cursor is normally positioned from rrweb's own recorded mouse
|
|
409
|
+
* events — but a click that navigates loses them. The page-side event buffer
|
|
410
|
+
* lives on `window`, so everything the click emitted dies with the document
|
|
411
|
+
* before the post-op drain can collect it, and the step arrives carrying only
|
|
412
|
+
* the destination page's load. The step's own coordinates are then the sole
|
|
413
|
+
* record of where the click landed; without them the viewer has nothing to
|
|
414
|
+
* put the cursor on.
|
|
415
|
+
*
|
|
416
|
+
* Best-effort in every direction. A probe failure (not attached yet, strict
|
|
417
|
+
* violation, frame detached) leaves the step unstamped and lets the action
|
|
418
|
+
* itself report the problem. We only stamp a centre that is inside the
|
|
419
|
+
* viewport of the top-level document, because that is exactly the case where
|
|
420
|
+
* playwright clicks the element where it currently sits — one that is
|
|
421
|
+
* off-screen (or in an iframe, whose rect is frame-relative) gets scrolled
|
|
422
|
+
* into view first, which moves the point we measured. */
|
|
423
|
+
async function stampActionPoint(
|
|
424
|
+
loc: PWLocator,
|
|
425
|
+
rec: Partial<RecordableFields>,
|
|
426
|
+
position?: { x: number; y: number },
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
try {
|
|
429
|
+
const pt = await loc.evaluate(
|
|
430
|
+
(el, pos) => {
|
|
431
|
+
if (window.top !== window) return null;
|
|
432
|
+
const r = el.getBoundingClientRect();
|
|
433
|
+
if (!r.width || !r.height) return null;
|
|
434
|
+
// Playwright clicks the element's centre unless the caller named a
|
|
435
|
+
// point — which it takes relative to the PADDING box, so an element
|
|
436
|
+
// with a border (a plain `<button>` has 2px of it) sits that far off
|
|
437
|
+
// the rect we measured.
|
|
438
|
+
const cs = pos ? getComputedStyle(el) : null;
|
|
439
|
+
const x = cs ? r.left + parseFloat(cs.borderLeftWidth) + pos!.x : r.left + r.width / 2;
|
|
440
|
+
const y = cs ? r.top + parseFloat(cs.borderTopWidth) + pos!.y : r.top + r.height / 2;
|
|
441
|
+
const onScreen =
|
|
442
|
+
x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
|
|
443
|
+
return onScreen ? { x, y } : null;
|
|
444
|
+
},
|
|
445
|
+
position,
|
|
446
|
+
{ timeout: POINT_PROBE_MS },
|
|
447
|
+
);
|
|
448
|
+
if (pt) {
|
|
449
|
+
rec.x = Math.round(pt.x);
|
|
450
|
+
rec.y = Math.round(pt.y);
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
/* Element not ready / gone / strict violation — the action reports it. */
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
458
|
+
// The Locator interface
|
|
459
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A lazy reference to element(s), resolved with auto-waiting at the moment an
|
|
463
|
+
* action runs. Mirrors playwright-core's `Locator`: STRICT by default — a
|
|
464
|
+
* locator resolving to multiple elements throws a strict-mode violation at
|
|
465
|
+
* action time rather than silently acting on one. Narrow with `exact: true`,
|
|
466
|
+
* a testid, `filter(...)`, or the explicit `first()`/`last()`/`nth(i)`
|
|
467
|
+
* opt-outs.
|
|
468
|
+
*/
|
|
469
|
+
export interface Locator {
|
|
470
|
+
// ── Composition (lazy; return a new Locator) ──────────────────────────
|
|
471
|
+
/** Descendant CSS query, scoped to this locator's subtree. */
|
|
472
|
+
locator(css: string): Locator;
|
|
473
|
+
getByRole(role: string, opts?: GetByRoleOptions): Locator;
|
|
474
|
+
getByText(text: string | RegExp, opts?: GetByTextOptions): Locator;
|
|
475
|
+
getByLabel(text: string | RegExp, opts?: GetByTextOptions): Locator;
|
|
476
|
+
getByPlaceholder(text: string | RegExp, opts?: GetByTextOptions): Locator;
|
|
477
|
+
getByAltText(text: string | RegExp, opts?: GetByTextOptions): Locator;
|
|
478
|
+
getByTitle(text: string | RegExp, opts?: GetByTextOptions): Locator;
|
|
479
|
+
getByTestId(testId: string): Locator;
|
|
480
|
+
/** Narrow the current match set by content or by a descendant locator. */
|
|
481
|
+
filter(opts: FilterOptions): Locator;
|
|
482
|
+
/** Intersect with another locator (both must match the same element). */
|
|
483
|
+
and(other: Locator): Locator;
|
|
484
|
+
/** Union with another locator (either may match). */
|
|
485
|
+
or(other: Locator): Locator;
|
|
486
|
+
/** Explicit strict-mode opt-out: the first resolved element. */
|
|
487
|
+
first(): Locator;
|
|
488
|
+
/** Explicit strict-mode opt-out: the last resolved element. */
|
|
489
|
+
last(): Locator;
|
|
490
|
+
/** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
|
|
491
|
+
nth(index: number): Locator;
|
|
492
|
+
|
|
493
|
+
// ── Actions (auto-wait; default 5s, `{ timeout }` overrides) ──────────
|
|
494
|
+
click(opts?: ClickOptions): Promise<void>;
|
|
495
|
+
dblclick(opts?: ClickOptions): Promise<void>;
|
|
496
|
+
/** Touch-tap (mobile sessions only; throws on desktop). `duration`
|
|
497
|
+
* overrides the press dwell. */
|
|
498
|
+
tap(opts?: { timeout?: number; duration?: number }): Promise<void>;
|
|
499
|
+
/** Set an input/textarea's value (React-safe: native setter + input event). */
|
|
500
|
+
fill(value: string, opts?: TimeoutOption): Promise<void>;
|
|
501
|
+
/** Clear an input's value (`fill("")`). */
|
|
502
|
+
clear(opts?: TimeoutOption): Promise<void>;
|
|
503
|
+
/** Focus and press a single key or chord (`"Enter"`, `"Control+A"`). */
|
|
504
|
+
press(key: string, opts?: TimeoutOption): Promise<void>;
|
|
505
|
+
/** Focus and type character-by-character (fires keydown/keyup per char). */
|
|
506
|
+
pressSequentially(text: string, opts?: { timeout?: number; delay?: number }): Promise<void>;
|
|
507
|
+
check(opts?: TimeoutOption): Promise<void>;
|
|
508
|
+
uncheck(opts?: TimeoutOption): Promise<void>;
|
|
509
|
+
setChecked(checked: boolean, opts?: TimeoutOption): Promise<void>;
|
|
510
|
+
/** Select `<option>`(s) by value/label/index; returns the selected values,
|
|
511
|
+
* provenance-wrapped so `expect(...)` on them nests under this step. */
|
|
512
|
+
selectOption(
|
|
513
|
+
values: string | string[] | { label?: string; value?: string; index?: number },
|
|
514
|
+
opts?: TimeoutOption,
|
|
515
|
+
): Promise<Wrapped<string[]>>;
|
|
516
|
+
hover(opts?: TimeoutOption): Promise<void>;
|
|
517
|
+
focus(opts?: TimeoutOption): Promise<void>;
|
|
518
|
+
blur(opts?: TimeoutOption): Promise<void>;
|
|
519
|
+
scrollIntoViewIfNeeded(opts?: TimeoutOption): Promise<void>;
|
|
520
|
+
dragTo(target: Locator, opts?: TimeoutOption): Promise<void>;
|
|
521
|
+
|
|
522
|
+
// ── Reads (wrapped so `expect(...)` nests under the step) ─────────────
|
|
523
|
+
textContent(opts?: TimeoutOption): Promise<Wrapped<string | null>>;
|
|
524
|
+
innerText(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
525
|
+
innerHTML(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
526
|
+
inputValue(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
527
|
+
getAttribute(name: string, opts?: TimeoutOption): Promise<Wrapped<string | null>>;
|
|
528
|
+
isVisible(): Promise<Wrapped<boolean>>;
|
|
529
|
+
isHidden(): Promise<Wrapped<boolean>>;
|
|
530
|
+
isEnabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
531
|
+
isDisabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
532
|
+
isChecked(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
533
|
+
isEditable(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
534
|
+
count(): Promise<Wrapped<number>>;
|
|
535
|
+
/** Resolve to one locator per current match (handle-free — each is pinned
|
|
536
|
+
* with `nth(i)`). One recorded event. */
|
|
537
|
+
all(): Promise<Locator[]>;
|
|
538
|
+
allTextContents(): Promise<Wrapped<string[]>>;
|
|
539
|
+
allInnerTexts(): Promise<Wrapped<string[]>>;
|
|
540
|
+
boundingBox(opts?: TimeoutOption): Promise<Wrapped<BoundingBox | null>>;
|
|
541
|
+
/** Evaluate a function against the resolved element. `description` labels
|
|
542
|
+
* the step in the timeline (spectest keeps it for readability). */
|
|
543
|
+
evaluate<T = unknown>(
|
|
544
|
+
description: string,
|
|
545
|
+
fn: string | ((el: Element, arg?: unknown) => T),
|
|
546
|
+
arg?: unknown,
|
|
547
|
+
): Promise<Wrapped<T>>;
|
|
548
|
+
|
|
549
|
+
/** Wait until the element reaches `state` (default `"visible"`). */
|
|
550
|
+
waitFor(opts?: { state?: WaitForState; timeout?: number }): Promise<void>;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
554
|
+
// Factory
|
|
555
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/** Build a lazy {@link Locator} over `chain`. `backend`/`strategy` are the
|
|
558
|
+
* session seams; composition methods extend the chain, terminal methods run
|
|
559
|
+
* through `backend.pageOp`. */
|
|
560
|
+
export function makeLocator(
|
|
561
|
+
backend: LocatorBackend,
|
|
562
|
+
strategy: ActionStrategy,
|
|
563
|
+
chain: Chain,
|
|
564
|
+
): Locator {
|
|
565
|
+
const label = chainLabel(chain);
|
|
566
|
+
const extend = (step: Step): Locator =>
|
|
567
|
+
makeLocator(backend, strategy, { steps: [...chain.steps, step] });
|
|
568
|
+
|
|
569
|
+
// One recorded event, result NOT wrapped (void/action).
|
|
570
|
+
const act = <T>(
|
|
571
|
+
action: string,
|
|
572
|
+
fields: Partial<RecordableFields>,
|
|
573
|
+
fn: (loc: PWLocator, page: Page) => Promise<T>,
|
|
574
|
+
): Promise<T> =>
|
|
575
|
+
backend.pageOp(action, { selector: label, ...fields }, (page) =>
|
|
576
|
+
fn(lower(page, chain), page),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// One recorded event whose fields the action itself finishes filling in:
|
|
580
|
+
// `rec` is the very object the recorder spreads once `fn` resolves (the
|
|
581
|
+
// same mutate-in-flight seam `screenshot`'s artifact id rides), so the
|
|
582
|
+
// click family can stamp the point it acted on. See stampActionPoint.
|
|
583
|
+
const actAt = <T>(
|
|
584
|
+
action: string,
|
|
585
|
+
fn: (loc: PWLocator, rec: Partial<RecordableFields>) => Promise<T>,
|
|
586
|
+
): Promise<T> => {
|
|
587
|
+
const rec: Partial<RecordableFields> = { selector: label };
|
|
588
|
+
return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// One recorded event, result provenance-wrapped for expect().
|
|
592
|
+
const read = <T>(
|
|
593
|
+
action: string,
|
|
594
|
+
fn: (loc: PWLocator) => Promise<T>,
|
|
595
|
+
fields: Partial<RecordableFields> = {},
|
|
596
|
+
): Promise<Wrapped<T>> =>
|
|
597
|
+
backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
|
|
598
|
+
wrap: true,
|
|
599
|
+
}) as Promise<Wrapped<T>>;
|
|
600
|
+
|
|
601
|
+
// Silent, non-recorded reads for expect(locator) matchers to poll.
|
|
602
|
+
const probe: LocatorProbe = {
|
|
603
|
+
label,
|
|
604
|
+
isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
|
|
605
|
+
textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
|
|
606
|
+
inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
|
|
607
|
+
count: () => backend.silentRead((page) => lower(page, chain).count()),
|
|
608
|
+
isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
|
|
609
|
+
isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
|
|
610
|
+
settle: (action, waitedMs, error) =>
|
|
611
|
+
backend.recordSettled(action, { selector: label }, waitedMs, error),
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const loc: InternalLocator = {
|
|
615
|
+
[LOCATOR_BRAND]: true,
|
|
616
|
+
[CHAIN]: chain,
|
|
617
|
+
|
|
618
|
+
// composition
|
|
619
|
+
locator: (css) => extend({ m: "locator", args: [css] }),
|
|
620
|
+
getByRole: (role, opts) => extend({ m: "getByRole", args: [role, opts] }),
|
|
621
|
+
getByText: (text, opts) => extend({ m: "getByText", args: [text, opts] }),
|
|
622
|
+
getByLabel: (text, opts) => extend({ m: "getByLabel", args: [text, opts] }),
|
|
623
|
+
getByPlaceholder: (text, opts) => extend({ m: "getByPlaceholder", args: [text, opts] }),
|
|
624
|
+
getByAltText: (text, opts) => extend({ m: "getByAltText", args: [text, opts] }),
|
|
625
|
+
getByTitle: (text, opts) => extend({ m: "getByTitle", args: [text, opts] }),
|
|
626
|
+
getByTestId: (id) => extend({ m: "getByTestId", args: [id] }),
|
|
627
|
+
filter: (opts) => extend({ m: "filter", args: [opts] }),
|
|
628
|
+
and: (other) => extend({ m: "and", args: [other] }),
|
|
629
|
+
or: (other) => extend({ m: "or", args: [other] }),
|
|
630
|
+
first: () => extend({ m: "first", args: [] }),
|
|
631
|
+
last: () => extend({ m: "last", args: [] }),
|
|
632
|
+
nth: (i) => extend({ m: "nth", args: [i] }),
|
|
633
|
+
|
|
634
|
+
// actions
|
|
635
|
+
click: (opts) =>
|
|
636
|
+
actAt("click", async (l, rec) => {
|
|
637
|
+
await stampActionPoint(l, rec, opts?.position);
|
|
638
|
+
return l.click(opts);
|
|
639
|
+
}),
|
|
640
|
+
dblclick: (opts) =>
|
|
641
|
+
actAt("dblclick", async (l, rec) => {
|
|
642
|
+
await stampActionPoint(l, rec, opts?.position);
|
|
643
|
+
return l.dblclick(opts);
|
|
644
|
+
}),
|
|
645
|
+
tap: (opts) => actAt("tap", (l, rec) => strategy.tap(backend, l, opts, rec)),
|
|
646
|
+
fill: (value, opts) => {
|
|
647
|
+
const t = truncateUtf8(value);
|
|
648
|
+
return act("fill", { text: t.value, textTruncated: t.truncated }, (l) =>
|
|
649
|
+
l.fill(value, { timeout: opts?.timeout }),
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
clear: (opts) => act("clear", {}, (l) => l.clear({ timeout: opts?.timeout })),
|
|
653
|
+
press: (key, opts) => act("press", { key }, (l) => l.press(key, { timeout: opts?.timeout })),
|
|
654
|
+
pressSequentially: (text, opts) => {
|
|
655
|
+
const t = truncateUtf8(text);
|
|
656
|
+
return act("pressSequentially", { text: t.value, textTruncated: t.truncated }, (l) =>
|
|
657
|
+
l.pressSequentially(text, { timeout: opts?.timeout, delay: opts?.delay }),
|
|
658
|
+
);
|
|
659
|
+
},
|
|
660
|
+
check: (opts) => act("check", {}, (l) => l.check({ timeout: opts?.timeout })),
|
|
661
|
+
uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
|
|
662
|
+
setChecked: (checked, opts) =>
|
|
663
|
+
act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
|
|
664
|
+
selectOption: (values, opts) =>
|
|
665
|
+
read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
|
|
666
|
+
hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
|
|
667
|
+
focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
|
|
668
|
+
blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
|
|
669
|
+
scrollIntoViewIfNeeded: (opts) =>
|
|
670
|
+
act("scrollIntoViewIfNeeded", {}, (l) => l.scrollIntoViewIfNeeded({ timeout: opts?.timeout })),
|
|
671
|
+
dragTo: (target, opts) =>
|
|
672
|
+
act("dragTo", {}, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
|
|
673
|
+
|
|
674
|
+
// reads
|
|
675
|
+
textContent: (opts) => read("textContent", (l) => l.textContent({ timeout: opts?.timeout })),
|
|
676
|
+
innerText: (opts) => read("innerText", (l) => l.innerText({ timeout: opts?.timeout })),
|
|
677
|
+
innerHTML: (opts) => read("innerHTML", (l) => l.innerHTML({ timeout: opts?.timeout })),
|
|
678
|
+
inputValue: (opts) => read("inputValue", (l) => l.inputValue({ timeout: opts?.timeout })),
|
|
679
|
+
getAttribute: (name, opts) =>
|
|
680
|
+
read("getAttribute", (l) => l.getAttribute(name, { timeout: opts?.timeout }), {
|
|
681
|
+
attribute: name,
|
|
682
|
+
}),
|
|
683
|
+
isVisible: () => read("isVisible", (l) => l.isVisible()),
|
|
684
|
+
isHidden: () => read("isHidden", (l) => l.isHidden()),
|
|
685
|
+
isEnabled: (opts) => read("isEnabled", (l) => l.isEnabled({ timeout: opts?.timeout })),
|
|
686
|
+
isDisabled: (opts) => read("isDisabled", (l) => l.isDisabled({ timeout: opts?.timeout })),
|
|
687
|
+
isChecked: (opts) => read("isChecked", (l) => l.isChecked({ timeout: opts?.timeout })),
|
|
688
|
+
isEditable: (opts) => read("isEditable", (l) => l.isEditable({ timeout: opts?.timeout })),
|
|
689
|
+
count: () => read("count", (l) => l.count()),
|
|
690
|
+
allTextContents: () => read("allTextContents", (l) => l.allTextContents()),
|
|
691
|
+
allInnerTexts: () => read("allInnerTexts", (l) => l.allInnerTexts()),
|
|
692
|
+
boundingBox: (opts) => read("boundingBox", (l) => l.boundingBox({ timeout: opts?.timeout })),
|
|
693
|
+
async all() {
|
|
694
|
+
// count() lowered once (one event), then one child locator per match,
|
|
695
|
+
// each pinned with nth(i). Stays lazy + handle-free.
|
|
696
|
+
const n = await act("all", {}, (l) => l.count());
|
|
697
|
+
const out: Locator[] = [];
|
|
698
|
+
for (let i = 0; i < n; i++) out.push(extend({ m: "nth", args: [i] }));
|
|
699
|
+
return out;
|
|
700
|
+
},
|
|
701
|
+
evaluate: (description, fn, arg) =>
|
|
702
|
+
backend.pageOp(
|
|
703
|
+
"evaluate",
|
|
704
|
+
{ selector: label, description },
|
|
705
|
+
(page) => lower(page, chain).evaluate(fn as never, arg),
|
|
706
|
+
{ wrap: true },
|
|
707
|
+
) as Promise<Wrapped<never>>,
|
|
708
|
+
|
|
709
|
+
waitFor: (opts) =>
|
|
710
|
+
act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
|
|
711
|
+
};
|
|
712
|
+
// Attach the silent-read probe under its symbol (kept off the typed literal
|
|
713
|
+
// so declaration order stays simple).
|
|
714
|
+
(loc as unknown as Record<symbol, LocatorProbe>)[PROBE] = probe;
|
|
715
|
+
return loc;
|
|
716
|
+
}
|