@harnessed-ts/puppeteer 0.0.0-stage → 0.7.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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/driver-id.d.ts +3 -0
- package/dist/driver-id.d.ts.map +1 -0
- package/dist/env.d.ts +21 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/index.cjs +598 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +594 -0
- package/dist/index.js.map +1 -0
- package/dist/matchers.cjs +9 -0
- package/dist/matchers.cjs.map +1 -0
- package/dist/matchers.d.ts +9 -0
- package/dist/matchers.d.ts.map +1 -0
- package/dist/matchers.js +7 -0
- package/dist/matchers.js.map +1 -0
- package/dist/puppeteer-query.d.ts +80 -0
- package/dist/puppeteer-query.d.ts.map +1 -0
- package/dist/resolve.d.ts +80 -0
- package/dist/resolve.d.ts.map +1 -0
- package/package.json +77 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@harnessed-ts/core');
|
|
4
|
+
var inject = require('@harnessed-ts/resolve/inject');
|
|
5
|
+
|
|
6
|
+
// src/driver-id.ts
|
|
7
|
+
var PUPPETEER_DRIVER = "puppeteer";
|
|
8
|
+
var FrameEntryError = class extends Error {
|
|
9
|
+
name = "FrameEntryError";
|
|
10
|
+
};
|
|
11
|
+
var POLL_MS = 50;
|
|
12
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
function apiOptions(timeout) {
|
|
14
|
+
return { testIdAttribute: core.getConfig().testIdAttribute, timeout };
|
|
15
|
+
}
|
|
16
|
+
function isMissingApi(error) {
|
|
17
|
+
return error instanceof Error && error.message.includes(`${inject.PAGE_API_GLOBAL} is not installed`);
|
|
18
|
+
}
|
|
19
|
+
var NAVIGATION_ERRORS = [
|
|
20
|
+
"Execution context was destroyed",
|
|
21
|
+
"Cannot find context with specified id",
|
|
22
|
+
"Execution context is not available in detached frame",
|
|
23
|
+
"Attempted to use detached Frame"
|
|
24
|
+
];
|
|
25
|
+
function isNavigationError(error) {
|
|
26
|
+
return error instanceof Error && NAVIGATION_ERRORS.some((message) => error.message.includes(message));
|
|
27
|
+
}
|
|
28
|
+
function isNotFound(error) {
|
|
29
|
+
return error instanceof Error && error.message.startsWith("Unable to find");
|
|
30
|
+
}
|
|
31
|
+
function isAbsence(error) {
|
|
32
|
+
return isNotFound(error) || error instanceof Error && /^harnessed: index \d+ is out of range/.test(error.message);
|
|
33
|
+
}
|
|
34
|
+
function isResolverOutcome(error) {
|
|
35
|
+
if (error instanceof FrameEntryError) return false;
|
|
36
|
+
return isNotFound(error) || error instanceof Error && error.message.startsWith("harnessed: ");
|
|
37
|
+
}
|
|
38
|
+
async function withApi(frame, timeout, call) {
|
|
39
|
+
const deadline = Date.now() + core.timeoutFor(timeout);
|
|
40
|
+
let injected = false;
|
|
41
|
+
for (; ; ) {
|
|
42
|
+
try {
|
|
43
|
+
return await call(apiOptions(Math.max(0, deadline - Date.now())));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const expired = Date.now() >= deadline;
|
|
46
|
+
if (isMissingApi(error) && !(expired && injected)) {
|
|
47
|
+
injected = true;
|
|
48
|
+
try {
|
|
49
|
+
await frame.evaluate(inject.injectSource());
|
|
50
|
+
} catch (injectError) {
|
|
51
|
+
if (!isNavigationError(injectError) || frame.detached) throw injectError;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!isNavigationError(error) || frame.detached || expired) throw error;
|
|
56
|
+
await sleep(POLL_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function restate(error, prefix, scope, selector) {
|
|
61
|
+
if (prefix.length === 0 || !(error instanceof Error)) return error;
|
|
62
|
+
if (!error.message.startsWith("harnessed: ")) return error;
|
|
63
|
+
const head = scope[0] ?? selector;
|
|
64
|
+
const restated = core.describeScope(prefix, head);
|
|
65
|
+
error.message = error.message.replace(core.describeSelector(head), () => restated);
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
function pageOne(name, method, scope, selector, options) {
|
|
69
|
+
const api = globalThis[name];
|
|
70
|
+
if (api === void 0) throw new Error(`${name} is not installed`);
|
|
71
|
+
return api[method](null, scope, selector, options);
|
|
72
|
+
}
|
|
73
|
+
function pageCount(name, scope, selector, options) {
|
|
74
|
+
const api = globalThis[name];
|
|
75
|
+
if (api === void 0) throw new Error(`${name} is not installed`);
|
|
76
|
+
return api.count(null, scope, selector, options);
|
|
77
|
+
}
|
|
78
|
+
function pageVisible(element, name) {
|
|
79
|
+
const api = globalThis[name];
|
|
80
|
+
if (api === void 0) throw new Error(`${name} is not installed`);
|
|
81
|
+
return api.visible(element);
|
|
82
|
+
}
|
|
83
|
+
function pageFileTarget(element, name) {
|
|
84
|
+
const api = globalThis[name];
|
|
85
|
+
if (api === void 0) throw new Error(`${name} is not installed`);
|
|
86
|
+
return api.fileTarget(element);
|
|
87
|
+
}
|
|
88
|
+
async function fileTargetOf(element) {
|
|
89
|
+
return element.evaluate(pageFileTarget, inject.PAGE_API_GLOBAL);
|
|
90
|
+
}
|
|
91
|
+
async function visibleInOwnDocument(element) {
|
|
92
|
+
return element.evaluate(pageVisible, inject.PAGE_API_GLOBAL);
|
|
93
|
+
}
|
|
94
|
+
async function oneIn(frame, prefix, scope, selector, lookup, timeout) {
|
|
95
|
+
const method = lookup === "wait" ? "one" : "oneNow";
|
|
96
|
+
let handle;
|
|
97
|
+
try {
|
|
98
|
+
handle = await withApi(
|
|
99
|
+
frame,
|
|
100
|
+
timeout,
|
|
101
|
+
(options) => frame.evaluateHandle(
|
|
102
|
+
pageOne,
|
|
103
|
+
inject.PAGE_API_GLOBAL,
|
|
104
|
+
method,
|
|
105
|
+
scope.map(inject.encodeSelector),
|
|
106
|
+
inject.encodeSelector(selector),
|
|
107
|
+
options
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw restate(core.reviveStrictViolation(error), prefix, scope, selector);
|
|
112
|
+
}
|
|
113
|
+
const element = handle.asElement();
|
|
114
|
+
if (element === null) await handle.dispose();
|
|
115
|
+
return element;
|
|
116
|
+
}
|
|
117
|
+
async function countIn(frame, prefix, scope, selector, timeout) {
|
|
118
|
+
try {
|
|
119
|
+
return await withApi(
|
|
120
|
+
frame,
|
|
121
|
+
timeout,
|
|
122
|
+
(options) => frame.evaluate(
|
|
123
|
+
pageCount,
|
|
124
|
+
inject.PAGE_API_GLOBAL,
|
|
125
|
+
scope.map(inject.encodeSelector),
|
|
126
|
+
inject.encodeSelector(selector),
|
|
127
|
+
options
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw restate(core.reviveStrictViolation(error), prefix, scope, selector);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function enterFrames(page, scope, lookup, timeout) {
|
|
135
|
+
let frame = page.mainFrame();
|
|
136
|
+
let start = 0;
|
|
137
|
+
const hosts = [];
|
|
138
|
+
try {
|
|
139
|
+
for (const [index, link] of scope.entries()) {
|
|
140
|
+
if (link.frame !== true) continue;
|
|
141
|
+
const path = scope.slice(0, index);
|
|
142
|
+
const host = await oneIn(
|
|
143
|
+
frame,
|
|
144
|
+
scope.slice(0, start),
|
|
145
|
+
scope.slice(start, index),
|
|
146
|
+
link,
|
|
147
|
+
lookup,
|
|
148
|
+
timeout
|
|
149
|
+
);
|
|
150
|
+
if (host === null) {
|
|
151
|
+
await disposeAll(hosts);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
hosts.push(host);
|
|
155
|
+
const tag = await host.evaluate((element) => element.localName);
|
|
156
|
+
if (tag !== "iframe") {
|
|
157
|
+
throw new FrameEntryError(
|
|
158
|
+
`harnessed: ${core.describeScope(path, link)} is marked as a frame but is a <${tag}>, not an <iframe>.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const inner = await host.contentFrame();
|
|
162
|
+
if (inner === null) {
|
|
163
|
+
throw new FrameEntryError(
|
|
164
|
+
`harnessed: ${core.describeScope(path, link)} has no document to enter: the <iframe> is not attached to a frame.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
frame = inner;
|
|
168
|
+
start = index + 1;
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await disposeAll(hosts);
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
return { frame, scope: scope.slice(start), prefix: scope.slice(0, start), hosts };
|
|
175
|
+
}
|
|
176
|
+
async function disposeAll(handles) {
|
|
177
|
+
await Promise.all(handles.map((handle) => handle.dispose().catch(() => void 0)));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/puppeteer-query.ts
|
|
181
|
+
var SET_VALUE_TYPES = ["color", "date", "time", "datetime-local", "month", "range", "week"];
|
|
182
|
+
var NOT_EDITABLE = {
|
|
183
|
+
disabled: "it is disabled",
|
|
184
|
+
readonly: "it is readonly",
|
|
185
|
+
unfocused: "focus did not land on it"
|
|
186
|
+
};
|
|
187
|
+
function chord(key) {
|
|
188
|
+
if (key.length <= 1) return { modifiers: [], key };
|
|
189
|
+
const plus = key.endsWith("++");
|
|
190
|
+
const parts = (plus ? key.slice(0, -2) : key).split("+");
|
|
191
|
+
const main = plus ? "+" : parts.pop() ?? key;
|
|
192
|
+
return { modifiers: parts, key: main };
|
|
193
|
+
}
|
|
194
|
+
var PuppeteerQuery = class _PuppeteerQuery extends core.Query {
|
|
195
|
+
constructor(page, scope, selector) {
|
|
196
|
+
super(scope, selector);
|
|
197
|
+
this.page = page;
|
|
198
|
+
}
|
|
199
|
+
page;
|
|
200
|
+
clone(selector) {
|
|
201
|
+
return new _PuppeteerQuery(this.page, this.scope, selector);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The target and the frames crossed to reach it, or `null` under `now` when
|
|
205
|
+
* something on the way is not on screen yet. The caller owns every handle.
|
|
206
|
+
*/
|
|
207
|
+
async resolve(lookup, timeout) {
|
|
208
|
+
core.checkTestIds(this.scope, this.selector);
|
|
209
|
+
const entered = await enterFrames(this.page, this.scope, lookup, timeout);
|
|
210
|
+
if (entered === null) return null;
|
|
211
|
+
try {
|
|
212
|
+
const element = await oneIn(
|
|
213
|
+
entered.frame,
|
|
214
|
+
entered.prefix,
|
|
215
|
+
entered.scope,
|
|
216
|
+
this.selector,
|
|
217
|
+
lookup,
|
|
218
|
+
timeout
|
|
219
|
+
);
|
|
220
|
+
if (element === null) {
|
|
221
|
+
await disposeAll(entered.hosts);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return { element, entered };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await disposeAll(entered.hosts);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** The single target, waiting for it to appear. */
|
|
231
|
+
async target(timeout) {
|
|
232
|
+
const target = await this.resolve("wait", timeout);
|
|
233
|
+
if (target === null) {
|
|
234
|
+
throw new Error(`harnessed: ${core.describeScope(this.scope, this.selector)} resolved to nothing.`);
|
|
235
|
+
}
|
|
236
|
+
return target;
|
|
237
|
+
}
|
|
238
|
+
/** Resolves the single target, runs `use`, and lets go of every handle after. */
|
|
239
|
+
async withTarget(options, use) {
|
|
240
|
+
const target = await this.target(options?.timeout);
|
|
241
|
+
try {
|
|
242
|
+
return await use(target.element);
|
|
243
|
+
} finally {
|
|
244
|
+
await release(target);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Visible by the shared layout rule, in a visible frame. Each iframe crossed is
|
|
249
|
+
* judged in its own document, which is how a cross-origin frame is covered.
|
|
250
|
+
*/
|
|
251
|
+
async visible(target) {
|
|
252
|
+
for (const element of [...target.entered.hosts, target.element]) {
|
|
253
|
+
if (!await visibleInOwnDocument(element)) return false;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
/** Whether the target is visible now. Absent is not visible; ambiguity still throws. */
|
|
258
|
+
async visibleNow(timeout) {
|
|
259
|
+
let target;
|
|
260
|
+
try {
|
|
261
|
+
target = await this.resolve("now", timeout);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!isAbsence(error)) throw error;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
if (target === null) return false;
|
|
267
|
+
try {
|
|
268
|
+
return await this.visible(target);
|
|
269
|
+
} finally {
|
|
270
|
+
await release(target);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// --- interactions -------------------------------------------------------
|
|
274
|
+
async click(options) {
|
|
275
|
+
await this.withTarget(options, (element) => element.click());
|
|
276
|
+
}
|
|
277
|
+
async fill(value, options) {
|
|
278
|
+
await this.replaceValue("fill", value, options);
|
|
279
|
+
}
|
|
280
|
+
async clear(options) {
|
|
281
|
+
await this.replaceValue("clear", "", options);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Replaces the value, as Playwright's `fill()` does: select what is there, then
|
|
285
|
+
* insert the new text as one input event — or delete the selection, which is
|
|
286
|
+
* what makes `fill('')` clear rather than type nothing.
|
|
287
|
+
*
|
|
288
|
+
* The keys go to whatever has focus, so the target must be editable and hold
|
|
289
|
+
* focus first. Until it does — disabled, readonly, or focus refused — this
|
|
290
|
+
* waits, within the same timeout as the lookup, and then fails rather than
|
|
291
|
+
* typing into some other element.
|
|
292
|
+
*/
|
|
293
|
+
async replaceValue(action, value, options) {
|
|
294
|
+
const timeout = core.timeoutFor(options?.timeout);
|
|
295
|
+
const deadline = Date.now() + timeout;
|
|
296
|
+
await this.withTarget(options, async (element) => {
|
|
297
|
+
for (; ; ) {
|
|
298
|
+
const mode = await element.evaluate(prepareEdit, SET_VALUE_TYPES, value);
|
|
299
|
+
if (mode === "done") return;
|
|
300
|
+
if (mode === "type") break;
|
|
301
|
+
if (Date.now() >= deadline) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`harnessed: ${action}() could not edit ${core.describeScope(this.scope, this.selector)} within ${timeout}ms: ${NOT_EDITABLE[mode]}.`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
await sleep(POLL_MS);
|
|
307
|
+
}
|
|
308
|
+
if (value === "") await this.page.keyboard.press("Delete");
|
|
309
|
+
else await this.page.keyboard.sendCharacter(value);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async check(options) {
|
|
313
|
+
await this.setChecked(true, options);
|
|
314
|
+
}
|
|
315
|
+
async uncheck(options) {
|
|
316
|
+
await this.setChecked(false, options);
|
|
317
|
+
}
|
|
318
|
+
/** Idempotent, and verified: a click that did not change the state is an error. */
|
|
319
|
+
async setChecked(wanted, options) {
|
|
320
|
+
await this.withTarget(options, async (element) => {
|
|
321
|
+
if (await checkedOf(element) === wanted) return;
|
|
322
|
+
await element.click();
|
|
323
|
+
if (await checkedOf(element) !== wanted) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`harnessed: clicking ${core.describeScope(this.scope, this.selector)} did not ${wanted ? "check" : "uncheck"} it.`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Matches each value against an option's value, then its label, as Playwright
|
|
332
|
+
* does — and replaces a multi-select's selection rather than adding to it.
|
|
333
|
+
*/
|
|
334
|
+
async selectOption(value, options) {
|
|
335
|
+
const wanted = Array.isArray(value) ? value : [value];
|
|
336
|
+
await this.withTarget(
|
|
337
|
+
options,
|
|
338
|
+
(element) => element.evaluate((node, values) => {
|
|
339
|
+
if (node.localName !== "select") {
|
|
340
|
+
throw new Error(`harnessed: selectOption() needs a <select>, not a <${node.localName}>.`);
|
|
341
|
+
}
|
|
342
|
+
const select = node;
|
|
343
|
+
const all = [...select.options];
|
|
344
|
+
const picked = values.map(
|
|
345
|
+
(want) => all.find((option) => option.value === want) ?? all.find((o) => o.label === want)
|
|
346
|
+
);
|
|
347
|
+
const missing = values.filter((_, index) => picked[index] === void 0);
|
|
348
|
+
if (missing.length > 0) {
|
|
349
|
+
throw new Error(`harnessed: no option matches ${missing.map((m) => `"${m}"`).join(", ")}.`);
|
|
350
|
+
}
|
|
351
|
+
if (!select.multiple && picked.length > 1) {
|
|
352
|
+
throw new Error("harnessed: cannot select several options in a single-select.");
|
|
353
|
+
}
|
|
354
|
+
for (const option of all) option.selected = picked.includes(option);
|
|
355
|
+
select.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
356
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
357
|
+
}, wanted)
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
async hover(options) {
|
|
361
|
+
await this.withTarget(options, (element) => element.hover());
|
|
362
|
+
}
|
|
363
|
+
async focus(options) {
|
|
364
|
+
await this.withTarget(options, (element) => element.focus());
|
|
365
|
+
}
|
|
366
|
+
async blur(options) {
|
|
367
|
+
await this.withTarget(
|
|
368
|
+
options,
|
|
369
|
+
(element) => element.evaluate((node) => node.blur())
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
async press(key, options) {
|
|
373
|
+
await this.withTarget(options, async (element) => {
|
|
374
|
+
await element.focus();
|
|
375
|
+
const { modifiers, key: main } = chord(key);
|
|
376
|
+
for (const modifier of modifiers) await this.page.keyboard.down(modifier);
|
|
377
|
+
try {
|
|
378
|
+
await this.page.keyboard.press(main);
|
|
379
|
+
} finally {
|
|
380
|
+
for (const modifier of modifiers.reverse()) {
|
|
381
|
+
await this.page.keyboard.up(modifier);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Puppeteer sets files only from paths, so each File is written to disk
|
|
388
|
+
* first (see `filesOnDisk`): the name and bytes arrive, and the browser
|
|
389
|
+
* infers the type from the extension.
|
|
390
|
+
*/
|
|
391
|
+
async upload(files, options) {
|
|
392
|
+
const list = Array.isArray(files) ? files : [files];
|
|
393
|
+
await this.withTarget(options, async (element) => {
|
|
394
|
+
core.checkFileTarget(await fileTargetOf(element), list.length, this.scope, this.selector);
|
|
395
|
+
const paths = await inject.filesOnDisk(list);
|
|
396
|
+
const input = await element.evaluateHandle(
|
|
397
|
+
(node) => node.localName === "label" ? node.control : node
|
|
398
|
+
);
|
|
399
|
+
try {
|
|
400
|
+
await input.uploadFile(...paths);
|
|
401
|
+
} finally {
|
|
402
|
+
await input.dispose();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// --- observations -------------------------------------------------------
|
|
407
|
+
async text(options) {
|
|
408
|
+
return this.withTarget(
|
|
409
|
+
options,
|
|
410
|
+
(element) => element.evaluate((node) => (node.textContent ?? "").trim())
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
async inputValue(options) {
|
|
414
|
+
return this.withTarget(
|
|
415
|
+
options,
|
|
416
|
+
(element) => element.evaluate((node) => node.value ?? "")
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
async attribute(name, options) {
|
|
420
|
+
return this.withTarget(
|
|
421
|
+
options,
|
|
422
|
+
(element) => element.evaluate((node, attribute) => node.getAttribute(attribute), name)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
/** Puppeteer's own box, relative to the main frame; `null` (no box) reads as zeros. */
|
|
426
|
+
async boundingBox(options) {
|
|
427
|
+
const box = await this.withTarget(options, (element) => element.boundingBox());
|
|
428
|
+
return box ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
429
|
+
}
|
|
430
|
+
async isVisible(options) {
|
|
431
|
+
let target;
|
|
432
|
+
try {
|
|
433
|
+
target = await this.target(options?.timeout);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (!isAbsence(error)) throw error;
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
return await this.visible(target);
|
|
440
|
+
} finally {
|
|
441
|
+
await release(target);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async isEnabled(options) {
|
|
445
|
+
return this.withTarget(options, async (element) => {
|
|
446
|
+
const [disabled, aria] = await element.evaluate(
|
|
447
|
+
(node) => [node.matches(":disabled"), node.getAttribute("aria-disabled")]
|
|
448
|
+
);
|
|
449
|
+
return core.enabledFrom(disabled, aria);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async isChecked(options) {
|
|
453
|
+
return this.withTarget(options, (element) => checkedOf(element));
|
|
454
|
+
}
|
|
455
|
+
async selectedOptions(options) {
|
|
456
|
+
return this.withTarget(
|
|
457
|
+
options,
|
|
458
|
+
(element) => element.evaluate(
|
|
459
|
+
(node) => [...node.selectedOptions ?? []].map((option) => option.value)
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
// --- waiting ------------------------------------------------------------
|
|
464
|
+
/**
|
|
465
|
+
* Polls from Node rather than waiting inside the page: the target can sit
|
|
466
|
+
* behind frames, each its own document, and a navigation mid-wait would take
|
|
467
|
+
* an in-page wait down with the document it ran in. A poll a navigation tore
|
|
468
|
+
* down answers nothing, and the next poll asks the new document.
|
|
469
|
+
*/
|
|
470
|
+
async waitFor(state, options) {
|
|
471
|
+
const timeout = core.timeoutFor(options?.timeout);
|
|
472
|
+
const deadline = Date.now() + timeout;
|
|
473
|
+
for (; ; ) {
|
|
474
|
+
const visible = await this.visibleNow(Math.max(0, deadline - Date.now())).catch(
|
|
475
|
+
(error) => {
|
|
476
|
+
if (isNavigationError(error)) return void 0;
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
);
|
|
480
|
+
if (visible !== void 0 && visible === (state === "visible")) return;
|
|
481
|
+
if (Date.now() >= deadline) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`harnessed: ${core.describeScope(this.scope, this.selector)} did not become ${state} within ${timeout}ms.`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
await sleep(POLL_MS);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async count() {
|
|
490
|
+
core.checkTestIds(this.scope, this.selector);
|
|
491
|
+
let entered;
|
|
492
|
+
try {
|
|
493
|
+
entered = await enterFrames(this.page, this.scope, "wait");
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (!isResolverOutcome(error)) throw error;
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
if (entered === null) return 0;
|
|
499
|
+
try {
|
|
500
|
+
return await countIn(entered.frame, entered.prefix, entered.scope, this.selector);
|
|
501
|
+
} finally {
|
|
502
|
+
await disposeAll(entered.hosts);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
function prepareEdit(node, setValueTypes, next) {
|
|
507
|
+
const element = node;
|
|
508
|
+
const control = node.localName === "input" || node.localName === "textarea";
|
|
509
|
+
if (!control && !element.isContentEditable) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`harnessed: fill() needs an <input>, <textarea> or [contenteditable] element, not a <${node.localName}>.`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const input = node;
|
|
515
|
+
if (control && node.matches(":disabled")) return "disabled";
|
|
516
|
+
if (control && input.readOnly) return "readonly";
|
|
517
|
+
element.focus();
|
|
518
|
+
const active = node.ownerDocument.activeElement;
|
|
519
|
+
const host = active;
|
|
520
|
+
const focused = active === node || !control && host !== null && host.isContentEditable && host.contains(node);
|
|
521
|
+
if (!focused) return "unfocused";
|
|
522
|
+
if (node.localName === "input" && setValueTypes.includes(input.type)) {
|
|
523
|
+
input.value = next;
|
|
524
|
+
if (input.value !== next) throw new Error(`harnessed: malformed value "${next}".`);
|
|
525
|
+
node.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
526
|
+
node.dispatchEvent(new Event("change", { bubbles: true }));
|
|
527
|
+
return "done";
|
|
528
|
+
}
|
|
529
|
+
if (control) {
|
|
530
|
+
input.select();
|
|
531
|
+
return "type";
|
|
532
|
+
}
|
|
533
|
+
const range = node.ownerDocument.createRange();
|
|
534
|
+
range.selectNodeContents(node);
|
|
535
|
+
const selection = node.ownerDocument.getSelection();
|
|
536
|
+
selection?.removeAllRanges();
|
|
537
|
+
selection?.addRange(range);
|
|
538
|
+
return "type";
|
|
539
|
+
}
|
|
540
|
+
async function release(target) {
|
|
541
|
+
await disposeAll([target.element, ...target.entered.hosts]);
|
|
542
|
+
}
|
|
543
|
+
async function checkedOf(element) {
|
|
544
|
+
const [aria, native] = await element.evaluate(
|
|
545
|
+
(node) => [node.getAttribute("aria-checked"), Boolean(node.checked)]
|
|
546
|
+
);
|
|
547
|
+
return core.checkedFrom(aria, native);
|
|
548
|
+
}
|
|
549
|
+
core.registerDriver(PUPPETEER_DRIVER, (env, scope, selector) => {
|
|
550
|
+
return new PuppeteerQuery(env.page, scope, selector);
|
|
551
|
+
});
|
|
552
|
+
function absolute(env, url) {
|
|
553
|
+
const base = env.baseURL ?? env.page.url();
|
|
554
|
+
try {
|
|
555
|
+
return new URL(url, base).href;
|
|
556
|
+
} catch {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`harnessed: cannot resolve "${url}" against "${base}". Pass a baseURL: puppeteer(page, { baseURL: 'http://localhost:3000' }).`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
core.registerNavigation(PUPPETEER_DRIVER, {
|
|
563
|
+
async goto(env, url) {
|
|
564
|
+
const puppeteerEnv = env;
|
|
565
|
+
await puppeteerEnv.page.goto(absolute(puppeteerEnv, url), { waitUntil: "domcontentloaded" });
|
|
566
|
+
},
|
|
567
|
+
currentUrl(env) {
|
|
568
|
+
return env.page.url();
|
|
569
|
+
},
|
|
570
|
+
/**
|
|
571
|
+
* Polled from Node: `page.url()` is tracked by Puppeteer across every kind of
|
|
572
|
+
* navigation, including a client-side `history.pushState`, where an in-page
|
|
573
|
+
* wait would not survive a full navigation.
|
|
574
|
+
*/
|
|
575
|
+
async waitForUrl(env, matches, timeout) {
|
|
576
|
+
const { page } = env;
|
|
577
|
+
const deadline = Date.now() + timeout;
|
|
578
|
+
while (!matches(new URL(page.url()))) {
|
|
579
|
+
if (Date.now() >= deadline) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`harnessed: the URL did not match within ${timeout}ms; it is still ${page.url()}.`
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
await sleep(POLL_MS);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// src/env.ts
|
|
590
|
+
function puppeteer(page, options = {}) {
|
|
591
|
+
return { driver: PUPPETEER_DRIVER, page, baseURL: options.baseURL };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
exports.PUPPETEER_DRIVER = PUPPETEER_DRIVER;
|
|
595
|
+
exports.PuppeteerQuery = PuppeteerQuery;
|
|
596
|
+
exports.puppeteer = puppeteer;
|
|
597
|
+
//# sourceMappingURL=index.cjs.map
|
|
598
|
+
//# sourceMappingURL=index.cjs.map
|