@lingxia/test 0.18.0 → 0.19.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/clock.d.ts +30 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +99 -0
- package/dist/clock.js.map +1 -0
- package/dist/coverage.d.ts +25 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +92 -0
- package/dist/coverage.js.map +1 -0
- package/dist/deadline.d.ts +82 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +184 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dialogs.d.ts +28 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.js +74 -0
- package/dist/dialogs.js.map +1 -0
- package/dist/equal.d.ts +28 -0
- package/dist/equal.d.ts.map +1 -0
- package/dist/equal.js +149 -0
- package/dist/equal.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/expect.d.ts +35 -7
- package/dist/expect.d.ts.map +1 -1
- package/dist/expect.js +216 -68
- package/dist/expect.js.map +1 -1
- package/dist/fixture.d.ts +201 -23
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +1127 -157
- package/dist/fixture.js.map +1 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +78 -0
- package/dist/format.js.map +1 -1
- package/dist/host-types.d.ts +65 -0
- package/dist/host-types.d.ts.map +1 -0
- package/dist/host-types.js +2 -0
- package/dist/host-types.js.map +1 -0
- package/dist/host.d.ts +20 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +33 -1
- package/dist/host.js.map +1 -1
- package/dist/ids.d.ts +14 -0
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +37 -2
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/json-result.d.ts +3 -0
- package/dist/json-result.d.ts.map +1 -0
- package/dist/json-result.js +62 -0
- package/dist/json-result.js.map +1 -0
- package/dist/junit.d.ts +1 -1
- package/dist/junit.d.ts.map +1 -1
- package/dist/junit.js +32 -4
- package/dist/junit.js.map +1 -1
- package/dist/locator.d.ts +87 -9
- package/dist/locator.d.ts.map +1 -1
- package/dist/locator.js +413 -86
- package/dist/locator.js.map +1 -1
- package/dist/mock.d.ts +43 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +162 -0
- package/dist/mock.js.map +1 -0
- package/dist/network.d.ts +68 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +287 -0
- package/dist/network.js.map +1 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +414 -0
- package/dist/openapi.js.map +1 -0
- package/dist/pending.d.ts +84 -0
- package/dist/pending.d.ts.map +1 -0
- package/dist/pending.js +311 -0
- package/dist/pending.js.map +1 -0
- package/dist/redact.d.ts +41 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +168 -0
- package/dist/redact.js.map +1 -0
- package/dist/remote.d.ts +32 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +117 -0
- package/dist/remote.js.map +1 -0
- package/dist/report-extras.d.ts +16 -0
- package/dist/report-extras.d.ts.map +1 -0
- package/dist/report-extras.js +125 -0
- package/dist/report-extras.js.map +1 -0
- package/dist/report-types.d.ts +428 -0
- package/dist/report-types.d.ts.map +1 -0
- package/dist/report-types.js +6 -0
- package/dist/report-types.js.map +1 -0
- package/dist/report.d.ts +15 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +195 -19
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts +13 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +11 -0
- package/dist/runner.js.map +1 -0
- package/dist/runtime.d.ts +54 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1375 -97
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +29 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +463 -0
- package/dist/schema.js.map +1 -0
- package/dist/spec-api.d.ts +7 -2
- package/dist/spec-api.d.ts.map +1 -1
- package/dist/tags.d.ts +26 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +78 -0
- package/dist/tags.js.map +1 -0
- package/dist/types.d.ts +852 -165
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -5
- package/schemas/lxdev.schema.json +50 -0
- package/schemas/mock-config.schema.json +21 -0
- package/schemas/scenario.schema.json +180 -0
package/dist/locator.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
var _a;
|
|
2
2
|
import { cssEscape, formatValue } from "./format.js";
|
|
3
|
+
import { ActionDeadline, TimeoutError, errorCode, isElementRefusal, isPreDispatchPageError, isTransientPageError, isTransientTransportError, } from "./deadline.js";
|
|
4
|
+
import { resolveHost } from "./host.js";
|
|
3
5
|
import { displayLocation } from "./ids.js";
|
|
4
6
|
import { DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, } from "./version.js";
|
|
7
|
+
import { runnerSetTimeout } from "./pending.js";
|
|
8
|
+
/** Marks a locator across module copies, so `expect(locator)` retries it. */
|
|
9
|
+
export const LOCATOR_BRAND = Symbol.for("lingxia.test.locator");
|
|
5
10
|
export function testIdSelector(id) {
|
|
6
11
|
return `[data-testid="${cssEscape(id)}"]`;
|
|
7
12
|
}
|
|
8
13
|
export class PageLocator {
|
|
9
|
-
constructor(page, guard, record, selector, location, options = {}) {
|
|
14
|
+
constructor(page, guard, record, selector, location, options = {}, room = () => Number.POSITIVE_INFINITY, refine = {}) {
|
|
10
15
|
this.page = page;
|
|
11
16
|
this.guard = guard;
|
|
12
17
|
this.record = record;
|
|
13
18
|
this.location = location;
|
|
14
19
|
this.options = options;
|
|
20
|
+
this.room = room;
|
|
21
|
+
this.refine = refine;
|
|
22
|
+
this[_a] = true;
|
|
15
23
|
this.selector = selector;
|
|
16
24
|
this.options = { ...options };
|
|
25
|
+
this.refine = { ...refine };
|
|
17
26
|
if (!selector.trim())
|
|
18
27
|
throw new TypeError("Locator selector must not be empty");
|
|
19
28
|
if (options.index !== undefined && (!Number.isInteger(options.index) || options.index < 0)) {
|
|
@@ -21,55 +30,172 @@ export class PageLocator {
|
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
nth(index) {
|
|
24
|
-
return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, { ...this.options, index });
|
|
33
|
+
return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, { ...this.options, index }, this.room, { ...this.refine, last: false });
|
|
25
34
|
}
|
|
26
|
-
|
|
35
|
+
first() {
|
|
36
|
+
return this.nth(0);
|
|
37
|
+
}
|
|
38
|
+
last() {
|
|
39
|
+
const { index: _index, ...options } = this.options;
|
|
40
|
+
return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, options, this.room, { ...this.refine, last: true });
|
|
41
|
+
}
|
|
42
|
+
filter(options) {
|
|
43
|
+
const hasText = options?.hasText;
|
|
44
|
+
if (typeof hasText !== "string" && !(hasText instanceof RegExp)) {
|
|
45
|
+
throw new TypeError("filter() needs { hasText: string | RegExp }");
|
|
46
|
+
}
|
|
47
|
+
return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, this.options, this.room, { ...this.refine, hasText });
|
|
48
|
+
}
|
|
49
|
+
// Actions return the fixture's own call, not a wrapper around it: a call
|
|
50
|
+
// the spec never awaited is then the very promise the runtime stops.
|
|
51
|
+
press(key, options) {
|
|
27
52
|
if (!this.page.press)
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
return Promise.reject(new Error("This page driver does not support press"));
|
|
54
|
+
return this.act("press", options, (css, index) => this.page.press({ page: this.options.page, css, index, key }), `key=${formatValue(key)}`);
|
|
55
|
+
}
|
|
56
|
+
click(options) {
|
|
57
|
+
const force = options?.force === true;
|
|
58
|
+
return this.act("click", options, (css, index) => this.page.click({ page: this.options.page, css, index, ...(force ? { force } : {}) }));
|
|
59
|
+
}
|
|
60
|
+
fill(text, options) {
|
|
61
|
+
const force = options?.force === true;
|
|
62
|
+
return this.act("fill", options, (css, index) => this.page.fill({ page: this.options.page, css, text, index, ...(force ? { force } : {}) }), this.inputDetail(text));
|
|
63
|
+
}
|
|
64
|
+
type(text, options) {
|
|
65
|
+
return this.act("type", options, (css, index) => this.page.type({ page: this.options.page, css, text, index }), this.inputDetail(text));
|
|
66
|
+
}
|
|
67
|
+
waitFor(options) {
|
|
68
|
+
const state = options?.state ?? "visible";
|
|
69
|
+
if (!["attached", "detached", "visible", "hidden", "inViewport"].includes(state)) {
|
|
70
|
+
return Promise.reject(new TypeError(`Unknown locator state: ${String(state)}`));
|
|
71
|
+
}
|
|
72
|
+
const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
73
|
+
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
74
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || !Number.isFinite(interval) || interval <= 0) {
|
|
75
|
+
return Promise.reject(new TypeError("Wait timeout and interval must be positive finite numbers"));
|
|
76
|
+
}
|
|
77
|
+
const deadline = new ActionDeadline(timeout, this.room());
|
|
78
|
+
return this.record("page.waitFor", `${this.target()} ${state}`, async () => {
|
|
79
|
+
let reason = "not observed";
|
|
80
|
+
let cause;
|
|
81
|
+
while (!deadline.expired()) {
|
|
82
|
+
try {
|
|
83
|
+
const resolved = await this.resolve(deadline, "waitFor");
|
|
84
|
+
if (reachedState(resolved, state))
|
|
85
|
+
return;
|
|
86
|
+
reason = this.missText(resolved);
|
|
87
|
+
cause = undefined;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// A page mid-transition, or a read the transport dropped, is not an
|
|
91
|
+
// answer yet; anything else is.
|
|
92
|
+
if (!isTransientPageError(error) && !isTransientTransportError(error))
|
|
93
|
+
throw error;
|
|
94
|
+
reason = transientReason(error);
|
|
95
|
+
cause = error;
|
|
96
|
+
}
|
|
97
|
+
if (deadline.expired())
|
|
98
|
+
break;
|
|
99
|
+
await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
|
|
100
|
+
}
|
|
101
|
+
throw withCause(new TimeoutError([
|
|
102
|
+
`Timed out after ${deadline.elapsed()}ms waiting for ${formatValue(this.selector)} to be ${state}.`,
|
|
103
|
+
reason,
|
|
104
|
+
deadline.clampNote(),
|
|
105
|
+
`at ${this.where()}`,
|
|
106
|
+
].filter(Boolean).join("\n")), cause);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
count() {
|
|
110
|
+
return this.read("count", async () => (await this.resolve(undefined, "count")).count);
|
|
111
|
+
}
|
|
112
|
+
isVisible() {
|
|
113
|
+
return this.read("isVisible", async () => {
|
|
114
|
+
const resolved = await this.resolve(undefined, "isVisible");
|
|
115
|
+
if (resolved.kind === "many")
|
|
116
|
+
throw new Error(`isVisible(): ${this.missText(resolved)}; narrow it with nth(), first(), last() or filter()`);
|
|
117
|
+
return resolved.kind === "unique";
|
|
118
|
+
});
|
|
30
119
|
}
|
|
31
|
-
|
|
32
|
-
|
|
120
|
+
textContent() {
|
|
121
|
+
return this.read("textContent", async () => (await this.single("textContent")).text);
|
|
33
122
|
}
|
|
34
|
-
|
|
35
|
-
|
|
123
|
+
inputValue() {
|
|
124
|
+
return this.read("inputValue", async () => {
|
|
125
|
+
const resolved = await this.single("inputValue");
|
|
126
|
+
if (resolved.value === null)
|
|
127
|
+
throw new Error(`inputValue(): ${this.target()} has no value (not an input, textarea or select)`);
|
|
128
|
+
return resolved.value;
|
|
129
|
+
});
|
|
36
130
|
}
|
|
37
|
-
|
|
38
|
-
|
|
131
|
+
getAttribute(name) {
|
|
132
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
133
|
+
return Promise.reject(new TypeError("getAttribute(name) takes an attribute name"));
|
|
134
|
+
}
|
|
135
|
+
return this.read("getAttribute", async () => (await this.single("getAttribute", [name])).attributes?.[name] ?? null);
|
|
39
136
|
}
|
|
40
|
-
|
|
41
|
-
|
|
137
|
+
/** A traced one-shot read. */
|
|
138
|
+
read(verb, op) {
|
|
139
|
+
return this.record(`page.${verb}`, this.target(), op);
|
|
42
140
|
}
|
|
43
|
-
async
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
141
|
+
async single(verb, attributes = []) {
|
|
142
|
+
const resolved = await this.resolve(undefined, verb, attributes);
|
|
143
|
+
if (resolved.count !== 1) {
|
|
144
|
+
throw new Error(`${verb}(): ${this.missText(resolved)}` +
|
|
145
|
+
(resolved.kind === "many" ? "; narrow it with nth(), first(), last() or filter()" : ""));
|
|
146
|
+
}
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* One read of the locator's matches. With a deadline the query is raced
|
|
151
|
+
* against what is left of it, so a query that never returns fails `verb`.
|
|
152
|
+
*/
|
|
153
|
+
async resolve(deadline, verb = "resolve", attributes = []) {
|
|
154
|
+
const query = () => this.page.query({ page: this.options.page, css: this.selector, all: true });
|
|
155
|
+
const all = await this.guard(() => deadline ? deadline.call("page.query", query, () => this.callContext(verb)) : query());
|
|
156
|
+
const raw = Array.isArray(all.items) ? all.items : all.exists ? [all] : [];
|
|
157
|
+
// Every match keeps its DOM index: the driver addresses `css` + index.
|
|
158
|
+
// Text is read as the user sees it: `innerText` line breaks depend on
|
|
159
|
+
// layout (WebKit ends a flex row's text in "\n"), so whitespace is
|
|
160
|
+
// collapsed and trimmed before any matcher sees it.
|
|
161
|
+
const indexed = raw.map((item, position) => ({
|
|
162
|
+
...item,
|
|
163
|
+
index: item.index ?? position,
|
|
164
|
+
...(typeof item.text === "string" ? { text: normalizeText(item.text) } : {}),
|
|
165
|
+
}));
|
|
166
|
+
const hasText = this.refine.hasText;
|
|
167
|
+
const matches = hasText === undefined ? indexed : indexed.filter((item) => textMatches(item.text ?? "", hasText));
|
|
168
|
+
const pick = this.refine.last ? matches.length - 1 : this.options.index;
|
|
169
|
+
const picked = pick === undefined ? undefined : matches[pick];
|
|
170
|
+
const items = pick === undefined ? matches : picked ? [picked] : [];
|
|
171
|
+
const count = pick !== undefined ? items.length : hasText === undefined ? (all.count ?? items.length) : items.length;
|
|
49
172
|
const visibleItems = items.filter((item) => item.visible);
|
|
50
173
|
const visibleCount = visibleItems.length;
|
|
174
|
+
let resolved;
|
|
51
175
|
if (count === 0) {
|
|
52
|
-
|
|
176
|
+
resolved = {
|
|
53
177
|
count: 0,
|
|
54
178
|
visibleCount: 0,
|
|
55
179
|
attached: false,
|
|
56
180
|
visible: false,
|
|
181
|
+
inViewport: false,
|
|
57
182
|
text: "",
|
|
58
183
|
value: null,
|
|
59
184
|
index: 0,
|
|
60
185
|
kind: "nothing",
|
|
61
186
|
};
|
|
62
187
|
}
|
|
63
|
-
if (count > 1) {
|
|
64
|
-
|
|
188
|
+
else if (count > 1) {
|
|
189
|
+
resolved = { count, visibleCount, attached: true, visible: visibleCount > 0, inViewport: false,
|
|
65
190
|
text: items.map(item => item.text ?? "").join("\n"), value: null, index: 0, kind: "many" };
|
|
66
191
|
}
|
|
67
|
-
if (visibleCount === 0) {
|
|
68
|
-
|
|
192
|
+
else if (visibleCount === 0) {
|
|
193
|
+
resolved = {
|
|
69
194
|
count,
|
|
70
195
|
visibleCount: 0,
|
|
71
196
|
attached: true,
|
|
72
197
|
visible: false,
|
|
198
|
+
inViewport: false,
|
|
73
199
|
text: items[0]?.text ?? "",
|
|
74
200
|
value: items[0]?.value ?? null,
|
|
75
201
|
index: items[0]?.index ?? 0,
|
|
@@ -77,20 +203,48 @@ export class PageLocator {
|
|
|
77
203
|
kind: "hidden",
|
|
78
204
|
};
|
|
79
205
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
206
|
+
else {
|
|
207
|
+
const unique = visibleItems[0];
|
|
208
|
+
resolved = {
|
|
209
|
+
count,
|
|
210
|
+
visibleCount: 1,
|
|
211
|
+
attached: true,
|
|
212
|
+
visible: true,
|
|
213
|
+
inViewport: unique.inViewport === true,
|
|
214
|
+
text: unique.text ?? "",
|
|
215
|
+
value: unique.value ?? null,
|
|
216
|
+
index: unique.index ?? items.indexOf(unique),
|
|
217
|
+
enabled: unique.enabled, editable: unique.editable, rect: unique.rect,
|
|
218
|
+
kind: "unique",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (attributes.length > 0 && count === 1) {
|
|
222
|
+
resolved.attributes = await this.readAttributes(resolved.index, attributes, deadline, verb);
|
|
223
|
+
}
|
|
224
|
+
return resolved;
|
|
225
|
+
}
|
|
226
|
+
/** A read-only look at the single match's attributes. */
|
|
227
|
+
async readAttributes(index, names, deadline, verb) {
|
|
228
|
+
if (!this.page.eval)
|
|
229
|
+
throw new Error("This page driver cannot read attributes");
|
|
230
|
+
const script = `(() => {
|
|
231
|
+
const el = document.querySelectorAll(${JSON.stringify(this.selector)})[${index}];
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const name of ${JSON.stringify(names)}) out[name] = el ? el.getAttribute(name) : null;
|
|
234
|
+
return out;
|
|
235
|
+
})()`;
|
|
236
|
+
const read = () => this.page.eval({ page: this.options.page, script });
|
|
237
|
+
const value = await this.guard(() => deadline ? deadline.call("page.eval (attributes)", read, () => this.callContext(verb)) : read());
|
|
238
|
+
const out = {};
|
|
239
|
+
const record = value && typeof value === "object" ? value : {};
|
|
240
|
+
for (const name of names)
|
|
241
|
+
out[name] = typeof record[name] === "string" ? record[name] : null;
|
|
242
|
+
return out;
|
|
92
243
|
}
|
|
93
244
|
missText(resolved) {
|
|
245
|
+
if (resolved.kind === "unique" && !resolved.inViewport) {
|
|
246
|
+
return `locator ${formatValue(this.selector)} resolved to a visible element outside the viewport`;
|
|
247
|
+
}
|
|
94
248
|
if (resolved.kind === "nothing") {
|
|
95
249
|
return `locator ${formatValue(this.selector)} resolved to nothing`;
|
|
96
250
|
}
|
|
@@ -102,10 +256,37 @@ export class PageLocator {
|
|
|
102
256
|
}
|
|
103
257
|
return `locator ${formatValue(this.selector)} resolved to a visible element`;
|
|
104
258
|
}
|
|
105
|
-
|
|
259
|
+
/** The page instance this locator is bound to; `null` for the current page. */
|
|
260
|
+
get boundPage() {
|
|
261
|
+
return this.options.page ?? null;
|
|
262
|
+
}
|
|
263
|
+
/** The page, selector and narrowing, as the trace and failures show it. */
|
|
264
|
+
describe() {
|
|
265
|
+
return this.target();
|
|
266
|
+
}
|
|
267
|
+
inputDetail(text) {
|
|
268
|
+
// Locator names commonly reveal password fields. Declared --secret-arg
|
|
269
|
+
// values are also scrubbed from the final report by the run redactor.
|
|
270
|
+
if (/(?:password|passwd|passphrase|secret|token|credential|api[-_]?key)/i.test(this.selector)) {
|
|
271
|
+
return `value=[redacted, ${text.length} chars]`;
|
|
272
|
+
}
|
|
273
|
+
return `value=${formatValue(text)}`;
|
|
274
|
+
}
|
|
275
|
+
target() {
|
|
276
|
+
const filter = this.refine.hasText === undefined ? "" : ` filter(hasText=${formatValue(this.refine.hasText)})`;
|
|
277
|
+
const pick = this.refine.last ? " [last]" : this.options.index === undefined ? "" : ` [${this.options.index}]`;
|
|
278
|
+
return `${this.options.page ? `#${this.options.page} ` : ""}${this.selector}${filter}${pick}`;
|
|
279
|
+
}
|
|
280
|
+
where() {
|
|
281
|
+
return displayLocation(this.location.source, this.location.line, this.location.column);
|
|
282
|
+
}
|
|
283
|
+
callContext(verb) {
|
|
284
|
+
return `while trying to ${verb} ${formatValue(this.selector)}\nat ${this.where()}`;
|
|
285
|
+
}
|
|
286
|
+
async actionability(index, verb, deadline) {
|
|
106
287
|
if (!this.page.eval)
|
|
107
288
|
return true;
|
|
108
|
-
const
|
|
289
|
+
const script = `(() => {
|
|
109
290
|
const el = document.querySelectorAll(${JSON.stringify(this.selector)})[${index}];
|
|
110
291
|
if (!el || !el.isConnected) return "element detached";
|
|
111
292
|
el.scrollIntoView({block:"center", inline:"center", behavior:"instant"});
|
|
@@ -114,70 +295,216 @@ export class PageLocator {
|
|
|
114
295
|
const r = el.getBoundingClientRect();
|
|
115
296
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
116
297
|
return hit && (hit === el || el.contains(hit)) ? true : "element is obscured";
|
|
117
|
-
})()
|
|
298
|
+
})()`;
|
|
299
|
+
const result = await this.guard(() => deadline.call("page.eval (actionability)", () => this.page.eval({ page: this.options.page, script }), () => this.callContext(verb)));
|
|
118
300
|
return result === true ? true : String(result ?? "invalid actionability response");
|
|
119
301
|
}
|
|
120
|
-
|
|
302
|
+
act(verb, options, run, input) {
|
|
303
|
+
const force = options?.force === true;
|
|
121
304
|
const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
122
|
-
const interval =
|
|
123
|
-
if (!Number.isFinite(timeout) || timeout <= 0
|
|
124
|
-
|
|
305
|
+
const interval = DEFAULT_POLL_INTERVAL_MS;
|
|
306
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
307
|
+
return Promise.reject(new TypeError("Action timeout must be a positive finite number"));
|
|
125
308
|
}
|
|
126
|
-
|
|
127
|
-
|
|
309
|
+
const deadline = new ActionDeadline(timeout, this.room());
|
|
310
|
+
const context = () => this.callContext(verb);
|
|
311
|
+
return this.record(`page.${verb}`, `${this.target()}${input ? ` ${input}` : ""}`, async () => {
|
|
128
312
|
let last;
|
|
129
313
|
let previousRect;
|
|
130
314
|
let reason = "not attached";
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
315
|
+
// Preserve the last driver failure as the timeout cause.
|
|
316
|
+
let cause;
|
|
317
|
+
const until = async (budget) => {
|
|
318
|
+
while (!budget.expired()) {
|
|
319
|
+
try {
|
|
320
|
+
last = await this.resolve(budget, verb);
|
|
321
|
+
reason = this.missText(last);
|
|
322
|
+
cause = undefined;
|
|
323
|
+
if (force && last.count === 1) {
|
|
324
|
+
// Forced: attached and enabled are enough; no viewport, stability
|
|
325
|
+
// or hit-test wait.
|
|
326
|
+
if (last.enabled === false)
|
|
327
|
+
reason = "element is disabled";
|
|
328
|
+
else if ((verb === "fill" || verb === "type") && last.editable === false)
|
|
329
|
+
reason = "element is not editable";
|
|
330
|
+
else {
|
|
331
|
+
try {
|
|
332
|
+
const index = last.index;
|
|
333
|
+
await this.guard(() => budget.call(`page.${verb}`, () => run(this.selector, index), context));
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (!isElementRefusal(error) && !isPreDispatchPageError(error)) {
|
|
338
|
+
throw new DispatchFailure(error);
|
|
339
|
+
}
|
|
340
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
341
|
+
cause = error;
|
|
342
|
+
}
|
|
153
343
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
reason =
|
|
161
|
-
|
|
344
|
+
}
|
|
345
|
+
else if (last.kind === "unique") {
|
|
346
|
+
const rect = JSON.stringify(last.rect);
|
|
347
|
+
const stable = last.rect === undefined || previousRect === rect;
|
|
348
|
+
previousRect = rect;
|
|
349
|
+
if (last.enabled === false)
|
|
350
|
+
reason = "element is disabled";
|
|
351
|
+
else if ((verb === "fill" || verb === "type") && last.editable === false)
|
|
352
|
+
reason = "element is not editable";
|
|
353
|
+
else if (!stable)
|
|
354
|
+
reason = "element is moving";
|
|
355
|
+
else {
|
|
356
|
+
const check = await this.actionability(last.index, verb, budget);
|
|
357
|
+
if (check === true) {
|
|
358
|
+
try {
|
|
359
|
+
const index = last.index;
|
|
360
|
+
await this.guard(() => budget.call(`page.${verb}`, () => run(this.selector, index), context));
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
// These native errors are raised before input dispatch. A transport
|
|
365
|
+
// failure is ambiguous and must never resubmit an action.
|
|
366
|
+
if (!isElementRefusal(error) && !isPreDispatchPageError(error)) {
|
|
367
|
+
throw new DispatchFailure(error);
|
|
368
|
+
}
|
|
369
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
370
|
+
cause = error;
|
|
371
|
+
previousRect = undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (check !== true)
|
|
375
|
+
reason = check;
|
|
162
376
|
}
|
|
163
377
|
}
|
|
164
|
-
|
|
165
|
-
|
|
378
|
+
else {
|
|
379
|
+
previousRect = undefined;
|
|
380
|
+
}
|
|
166
381
|
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
// The page is being replaced (navigation in flight): no read or
|
|
384
|
+
// dispatch reached it, so wait for the new page within the budget.
|
|
385
|
+
if (error instanceof DispatchFailure)
|
|
386
|
+
throw error.error;
|
|
387
|
+
// Reads only: a dispatch error is a DispatchFailure, never retried.
|
|
388
|
+
if (!isTransientPageError(error) && !isTransientTransportError(error))
|
|
389
|
+
throw error;
|
|
390
|
+
reason = transientReason(error);
|
|
391
|
+
cause = error;
|
|
392
|
+
previousRect = undefined;
|
|
393
|
+
}
|
|
394
|
+
await sleep(Math.min(interval, Math.max(1, budget.remaining())));
|
|
167
395
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
396
|
+
return false;
|
|
397
|
+
};
|
|
398
|
+
if (await until(deadline))
|
|
399
|
+
return;
|
|
400
|
+
throw withCause(new TimeoutError([
|
|
401
|
+
`Timed out after ${deadline.elapsed()}ms waiting to ${verb} ${formatValue(this.selector)}.`,
|
|
402
|
+
reason,
|
|
403
|
+
deadline.clampNote(),
|
|
404
|
+
`at ${this.where()}`,
|
|
405
|
+
].filter(Boolean).join("\n")), cause);
|
|
175
406
|
});
|
|
176
407
|
}
|
|
177
408
|
}
|
|
409
|
+
_a = LOCATOR_BRAND;
|
|
410
|
+
/** Preserve the last driver failure without changing the public timeout code. */
|
|
411
|
+
function withCause(error, cause) {
|
|
412
|
+
if (cause === undefined)
|
|
413
|
+
return error;
|
|
414
|
+
Object.assign(error, { cause });
|
|
415
|
+
const code = errorCode(cause);
|
|
416
|
+
const data = cause && typeof cause === "object" ? cause.data : undefined;
|
|
417
|
+
error.data = {
|
|
418
|
+
...(data && typeof data === "object" ? data : {}),
|
|
419
|
+
...(code === undefined ? {} : { driverCode: code }),
|
|
420
|
+
};
|
|
421
|
+
return error;
|
|
422
|
+
}
|
|
423
|
+
/** A dispatch error that must propagate as is, never be retried. */
|
|
424
|
+
class DispatchFailure {
|
|
425
|
+
constructor(error) {
|
|
426
|
+
this.error = error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function transientReason(error) {
|
|
430
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
431
|
+
return isTransientTransportError(error) ? `read dropped by the transport: ${text}` : `page not ready: ${text}`;
|
|
432
|
+
}
|
|
433
|
+
/** `many` never satisfies `attached`/`visible`: the match is ambiguous. */
|
|
434
|
+
function reachedState(resolved, state) {
|
|
435
|
+
switch (state) {
|
|
436
|
+
case "attached":
|
|
437
|
+
return resolved.kind === "unique" || resolved.kind === "hidden";
|
|
438
|
+
case "visible":
|
|
439
|
+
return resolved.kind === "unique";
|
|
440
|
+
case "hidden":
|
|
441
|
+
return resolved.visibleCount === 0;
|
|
442
|
+
case "detached":
|
|
443
|
+
return resolved.kind === "nothing";
|
|
444
|
+
case "inViewport":
|
|
445
|
+
return resolved.kind === "unique" && resolved.inViewport;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** How long the visibility probe waits for an animation frame. */
|
|
449
|
+
const VISIBILITY_FRAME_WAIT_MS = 300;
|
|
450
|
+
/** The probe's whole budget: a hidden page also throttles its timers. */
|
|
451
|
+
export const VISIBILITY_PROBE_BUDGET_MS = 1500;
|
|
452
|
+
/**
|
|
453
|
+
* Reads `document.visibilityState` and whether an animation frame arrives.
|
|
454
|
+
* WebKit stops animation frames for a page that is hidden or in a fully
|
|
455
|
+
* covered window, so rAF- and transition-driven UI (a sheet sliding in)
|
|
456
|
+
* never moves and actionability reads it as obscured.
|
|
457
|
+
*/
|
|
458
|
+
export const VISIBILITY_PROBE_SCRIPT = `new Promise((resolve) => {
|
|
459
|
+
let done = false;
|
|
460
|
+
const finish = (frame) => {
|
|
461
|
+
if (done) return;
|
|
462
|
+
done = true;
|
|
463
|
+
resolve({ state: String(document.visibilityState || "unknown"), animationFrames: frame });
|
|
464
|
+
};
|
|
465
|
+
try { requestAnimationFrame(() => finish(true)); } catch (_) { finish(false); }
|
|
466
|
+
setTimeout(() => finish(false), ${VISIBILITY_FRAME_WAIT_MS});
|
|
467
|
+
})`;
|
|
468
|
+
/**
|
|
469
|
+
* What the probe can say: what it observed, and what usually causes that. It
|
|
470
|
+
* cannot see the window, so the cause stays a likely one.
|
|
471
|
+
*/
|
|
472
|
+
export const HIDDEN_PAGE_NOTE = "page looked hidden or paused";
|
|
473
|
+
const HIDDEN_PAGE_CAUSE = "a covered window or a sleeping display pauses a page's animations";
|
|
474
|
+
/** Why every page is hidden when the host can tell the screen is locked. */
|
|
475
|
+
export const SCREEN_LOCKED_NOTE = "the screen is locked; unlock it — animations and sheets are paused";
|
|
476
|
+
/** A hidden-page note, naming the lock when that is the cause. */
|
|
477
|
+
export function hiddenCause(note) {
|
|
478
|
+
return note !== undefined && resolveHost().screenLocked() ? SCREEN_LOCKED_NOTE : note;
|
|
479
|
+
}
|
|
480
|
+
/** What the probe saw, or `undefined` for an unreadable answer. */
|
|
481
|
+
export function pageVisibility(value) {
|
|
482
|
+
if (!value || typeof value !== "object")
|
|
483
|
+
return undefined;
|
|
484
|
+
const record = value;
|
|
485
|
+
if (typeof record.state !== "string" || typeof record.animationFrames !== "boolean")
|
|
486
|
+
return undefined;
|
|
487
|
+
const hidden = record.state === "hidden" || !record.animationFrames;
|
|
488
|
+
return {
|
|
489
|
+
state: record.state,
|
|
490
|
+
animationFrames: record.animationFrames,
|
|
491
|
+
...(hidden ? { note: `${HIDDEN_PAGE_NOTE}: visibilityState "${record.state}", ${record.animationFrames ? "animation frames running" : `no animation frame observed within ${VISIBILITY_FRAME_WAIT_MS}ms`} (${HIDDEN_PAGE_CAUSE})` } : {}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/** Collapse every run of whitespace (newlines included) to one space and trim. */
|
|
495
|
+
export function normalizeText(value) {
|
|
496
|
+
return value.replace(/\s+/g, " ").trim();
|
|
497
|
+
}
|
|
498
|
+
export function textMatches(text, expected) {
|
|
499
|
+
if (expected instanceof RegExp) {
|
|
500
|
+
expected.lastIndex = 0;
|
|
501
|
+
return expected.test(text);
|
|
502
|
+
}
|
|
503
|
+
return normalizeText(text).toLowerCase().includes(normalizeText(expected).toLowerCase());
|
|
504
|
+
}
|
|
178
505
|
export function sleep(ms) {
|
|
179
506
|
return new Promise((resolve) => {
|
|
180
|
-
|
|
507
|
+
runnerSetTimeout(resolve, ms);
|
|
181
508
|
});
|
|
182
509
|
}
|
|
183
510
|
//# sourceMappingURL=locator.js.map
|