@e2e-dev/web 0.11.2 → 0.12.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/README.md +15 -9
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +54 -5
- package/dist/attempt-session.d.ts +1 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/browser-connection.d.ts +7 -3
- package/dist/browser-connection.js +7 -1
- package/dist/browser.d.ts +43 -6
- package/dist/browser.js +87 -36
- package/dist/capture-scope.d.ts +1 -1
- package/dist/cdp-recovery.d.ts +1 -1
- package/dist/cdp-selectors.d.ts +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +42 -0
- package/dist/dialogs.d.ts +1 -1
- package/dist/downloads.d.ts +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +74 -16
- package/dist/evaluation.d.ts +2 -0
- package/dist/evaluation.js +3 -1
- package/dist/in-page/read-semantics.d.ts +1 -0
- package/dist/in-page/read-semantics.js +151 -39
- package/dist/index.d.ts +2 -1
- package/dist/init-scripts.d.ts +30 -0
- package/dist/init-scripts.js +105 -0
- package/dist/install.d.ts +8 -1
- package/dist/install.js +35 -8
- package/dist/locators.d.ts +1 -1
- package/dist/locators.js +30 -18
- package/dist/observation-capture.d.ts +1 -1
- package/dist/observation.d.ts +1 -1
- package/dist/observation.js +3 -3
- package/dist/observe.d.ts +1 -1
- package/dist/protected-app.d.ts +20 -4
- package/dist/protected-app.js +35 -4
- package/dist/provider-video.d.ts +1 -1
- package/dist/read-node.d.ts +9 -2
- package/dist/route-options.d.ts +44 -0
- package/dist/route-options.js +125 -0
- package/dist/selector-engines.js +2 -0
- package/dist/shown-selector.d.ts +20 -0
- package/dist/shown-selector.js +27 -0
- package/dist/support.d.ts +4 -2
- package/dist/support.js +1 -1
- package/dist/surface.d.ts +40 -3
- package/dist/surface.js +44 -5
- package/dist/video.d.ts +1 -1
- package/package.json +9 -5
package/dist/engine.js
CHANGED
|
@@ -10,6 +10,8 @@ import { createRequire } from 'node:module';
|
|
|
10
10
|
import { asBrowserProvider } from './provider.js';
|
|
11
11
|
import { PlaywrightSurface } from './surface.js';
|
|
12
12
|
import { createBrowserFixture } from './browser.js';
|
|
13
|
+
import { validateInitScripts } from './init-scripts.js';
|
|
14
|
+
import { FIELD_VALUE_CONTROL, headerProblem } from './protected-app.js';
|
|
13
15
|
/** App fields only a device target reads. */
|
|
14
16
|
const NATIVE_APP_KEYS = ['bundleId', 'appPath', 'launchArguments', 'permissions'];
|
|
15
17
|
/** Creates one Playwright engine: one browser per worker, one context per attempt. */
|
|
@@ -46,6 +48,12 @@ export function web(options = {}) {
|
|
|
46
48
|
validateTestIdAttribute(options.testIdAttribute);
|
|
47
49
|
if (options.userAgent !== undefined)
|
|
48
50
|
validateUserAgent(options.userAgent, options.headers);
|
|
51
|
+
if (options.locale !== undefined)
|
|
52
|
+
validateLocale(options.locale, options.headers);
|
|
53
|
+
if (options.timezoneId !== undefined)
|
|
54
|
+
validateTimezoneId(options.timezoneId);
|
|
55
|
+
if (options.initScripts !== undefined)
|
|
56
|
+
validateInitScripts(options.initScripts);
|
|
49
57
|
if (options.screencast !== undefined)
|
|
50
58
|
validateScreencast(options.screencast);
|
|
51
59
|
const reconnecting = options.connect?.reconnectEndpoint !== undefined;
|
|
@@ -55,8 +63,9 @@ export function web(options = {}) {
|
|
|
55
63
|
// A per-attempt lease rides the same persistent context as `reconnectEndpoint`, with the same limits.
|
|
56
64
|
const recoverable = reconnecting || provider?.scope === 'attempt';
|
|
57
65
|
const mode = provider === undefined ? 'connect.reconnectEndpoint' : `browser provider "${provider.name}" with scope "attempt"`;
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
const creationOptions = CREATION_OPTION_KEYS.filter((key) => options[key] !== undefined);
|
|
67
|
+
if (recoverable && creationOptions.length > 0) {
|
|
68
|
+
throw new ConfigurationError('INVALID_CONFIG', `${mode} uses a persistent context; ${creationOptions.join(', ')} ${creationOptions.length === 1 ? 'requires' : 'require'} a newly created context`);
|
|
60
69
|
}
|
|
61
70
|
const surface = new PlaywrightSurface(options);
|
|
62
71
|
const handle = defineEngine({
|
|
@@ -125,7 +134,12 @@ const WEB_OPTION_KEYS = Object.keys({
|
|
|
125
134
|
basicAuth: true,
|
|
126
135
|
testIdAttribute: true,
|
|
127
136
|
userAgent: true,
|
|
137
|
+
locale: true,
|
|
138
|
+
timezoneId: true,
|
|
139
|
+
initScripts: true,
|
|
128
140
|
});
|
|
141
|
+
/** The options applied when the engine creates a browser context, which a persistent context never is. */
|
|
142
|
+
const CREATION_OPTION_KEYS = ['headers', 'basicAuth', 'userAgent', 'locale', 'timezoneId'];
|
|
129
143
|
/**
|
|
130
144
|
* What a persistent context rules out, named with its cause: `what` replaces
|
|
131
145
|
* the browser context, which `mode` never does. A per-attempt lease starts
|
|
@@ -135,11 +149,6 @@ function persistentContextLimit(what, mode, leased) {
|
|
|
135
149
|
const remedy = leased ? '; every attempt already starts on a fresh browser, and scope "worker" can clear one mid-test' : '';
|
|
136
150
|
return new ConfigurationError('UNSUPPORTED_CAPABILITY', `${what} is unavailable with ${mode}: it replaces the browser context, and the attempt rides one persistent context${remedy}`);
|
|
137
151
|
}
|
|
138
|
-
/** An HTTP header field name: one or more `token` characters (RFC 9110). */
|
|
139
|
-
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
140
|
-
/** A control character no HTTP field value may carry; a horizontal tab is the one the grammar allows. */
|
|
141
|
-
// oxlint-disable-next-line no-control-regex -- the control characters are the point
|
|
142
|
-
const FIELD_VALUE_CONTROL = /[\u0000-\u0008\u000A-\u001F\u007F]/;
|
|
143
152
|
/** True for a plain object; the shape both options take. Config runs as JavaScript, so the types alone are no guard. */
|
|
144
153
|
function isRecord(value) {
|
|
145
154
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -155,15 +164,9 @@ function validateHeaders(headers) {
|
|
|
155
164
|
throw new ConfigurationError('INVALID_CONFIG', 'web({ headers }) must be an object of header name to value');
|
|
156
165
|
}
|
|
157
166
|
for (const [name, value] of Object.entries(headers)) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (typeof value !== 'string') {
|
|
162
|
-
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must be a string, got ${typeof value}`);
|
|
163
|
-
}
|
|
164
|
-
if (FIELD_VALUE_CONTROL.test(value)) {
|
|
165
|
-
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must not contain a control character`);
|
|
166
|
-
}
|
|
167
|
+
const problem = headerProblem(name, value);
|
|
168
|
+
if (problem !== undefined)
|
|
169
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) ${problem}`);
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
172
|
/** Refuses credentials the browser could not present: a missing field, or a `:` in the user name (RFC 7617). */
|
|
@@ -199,6 +202,61 @@ function validateUserAgent(userAgent, headers) {
|
|
|
199
202
|
throw new ConfigurationError('INVALID_CONFIG', 'web({ userAgent }) and a user-agent header in web({ headers }) conflict; set userAgent only');
|
|
200
203
|
}
|
|
201
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Refuses a locale that is not a BCP 47 tag, and one an `accept-language`
|
|
207
|
+
* header would override on the app's site while `navigator.language` kept
|
|
208
|
+
* reporting it.
|
|
209
|
+
*/
|
|
210
|
+
function validateLocale(locale, headers) {
|
|
211
|
+
if (typeof locale !== 'string' || locale === '' || !isLanguageTag(locale)) {
|
|
212
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ locale }) must be a BCP 47 language tag such as "de-DE", got ${JSON.stringify(locale)}`);
|
|
213
|
+
}
|
|
214
|
+
if (headers !== undefined && Object.keys(headers).some((name) => name.toLowerCase() === 'accept-language')) {
|
|
215
|
+
throw new ConfigurationError('INVALID_CONFIG', 'web({ locale }) and an accept-language header in web({ headers }) conflict; set locale only');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True when `tag` names a language the browser can run in: a Unicode locale
|
|
220
|
+
* `Intl.Locale` parses, other than the undetermined `und`, which Chromium
|
|
221
|
+
* refuses at context creation as it does a private-use-only tag.
|
|
222
|
+
*/
|
|
223
|
+
function isLanguageTag(tag) {
|
|
224
|
+
try {
|
|
225
|
+
const { language } = new Intl.Locale(tag);
|
|
226
|
+
return language !== undefined && language !== 'und';
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Refuses, at config load, a time zone the browser would otherwise refuse at
|
|
234
|
+
* the first context. Best effort: Node cannot tell a miscased alias
|
|
235
|
+
* (`us/eastern`) from the real one, so the browser's own `Invalid timezone ID`
|
|
236
|
+
* stays the final word.
|
|
237
|
+
*/
|
|
238
|
+
function validateTimezoneId(timezoneId) {
|
|
239
|
+
if (typeof timezoneId !== 'string' || timezoneId === '' || !isTimeZone(timezoneId)) {
|
|
240
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ timezoneId }) must be an IANA time zone such as "Europe/Berlin", got ${JSON.stringify(timezoneId)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* True when `timeZone` is an IANA name `Intl` resolves, spelled with its own
|
|
245
|
+
* case. Node also takes an offset (`+01:00`) and any casing (`europe/berlin`),
|
|
246
|
+
* both of which Chromium refuses; an alias resolving to another name
|
|
247
|
+
* (`US/Eastern`) is fine.
|
|
248
|
+
*/
|
|
249
|
+
function isTimeZone(timeZone) {
|
|
250
|
+
if (/^[+-]/.test(timeZone))
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
const resolved = new Intl.DateTimeFormat('en-US', { timeZone }).resolvedOptions().timeZone;
|
|
254
|
+
return resolved === timeZone || resolved.toLowerCase() !== timeZone.toLowerCase();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
202
260
|
/**
|
|
203
261
|
* An attribute name the document grammar accepts (XML `Name`, ASCII): a value
|
|
204
262
|
* outside it could never be on an element, so `getByTestId` would match nothing
|
package/dist/evaluation.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ type EvaluationResult = {
|
|
|
5
5
|
ok: false;
|
|
6
6
|
message: string;
|
|
7
7
|
};
|
|
8
|
+
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
9
|
+
export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
8
10
|
/**
|
|
9
11
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
10
12
|
*
|
package/dist/evaluation.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
|
|
2
2
|
import { TestError } from 'e2e/engine';
|
|
3
3
|
import { message } from './support.js';
|
|
4
|
+
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
5
|
+
export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
4
6
|
/**
|
|
5
7
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
6
8
|
*
|
|
@@ -13,7 +15,7 @@ export function compileEvaluation(source, hasArgument) {
|
|
|
13
15
|
try {
|
|
14
16
|
return new Function('arg', `
|
|
15
17
|
return (async () => {
|
|
16
|
-
|
|
18
|
+
${KEEP_NAMES_HELPER}
|
|
17
19
|
try {
|
|
18
20
|
return { ok: true, value: await (${source}\n)(${hasArgument ? 'arg' : ''}) };
|
|
19
21
|
} catch (cause) {
|
|
@@ -13,6 +13,7 @@ import type { SemanticMode, SemanticResult } from '../read-node.ts';
|
|
|
13
13
|
* `mode.kind === 'node'` reads exactly one element for locator reads, in
|
|
14
14
|
* the selector engine's task that found it (`read-selector.ts`,
|
|
15
15
|
* `label-selector.ts`), so the element is always in its document.
|
|
16
|
+
* `mode.kind === 'hidden'` answers that one element's `states.hidden` alone.
|
|
16
17
|
* `mode.kind === 'tree'` walks the subtree for one agent observation and
|
|
17
18
|
* returns live element handles aligned with the flattened node list. The two
|
|
18
19
|
* modes also project nodes differently; those differences are data (see
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `mode.kind === 'node'` reads exactly one element for locator reads, in
|
|
13
13
|
* the selector engine's task that found it (`read-selector.ts`,
|
|
14
14
|
* `label-selector.ts`), so the element is always in its document.
|
|
15
|
+
* `mode.kind === 'hidden'` answers that one element's `states.hidden` alone.
|
|
15
16
|
* `mode.kind === 'tree'` walks the subtree for one agent observation and
|
|
16
17
|
* returns live element handles aligned with the flattened node list. The two
|
|
17
18
|
* modes also project nodes differently; those differences are data (see
|
|
@@ -54,8 +55,10 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
54
55
|
/**
|
|
55
56
|
* How the active mode projects one node, expressed as data so `describe`
|
|
56
57
|
* stays branch-free. Tree mode is the model-bound projection: bounded text,
|
|
57
|
-
* a bounded attribute projection, hrefs reduced to origin+path,
|
|
58
|
-
* document named by its title
|
|
58
|
+
* a bounded attribute projection, hrefs reduced to origin+path, the root
|
|
59
|
+
* document named by its title, and no value on a checkbox or radio, whose
|
|
60
|
+
* value attribute is an app token its checked state already says more
|
|
61
|
+
* than. Node mode is the full locator-read surface.
|
|
59
62
|
*/
|
|
60
63
|
const projection = options.mode.kind === 'tree'
|
|
61
64
|
? {
|
|
@@ -65,6 +68,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
65
68
|
redactHref: true,
|
|
66
69
|
directTextOnly: true,
|
|
67
70
|
documentRoot: true,
|
|
71
|
+
checkableValue: false,
|
|
68
72
|
}
|
|
69
73
|
: {
|
|
70
74
|
attributes: null,
|
|
@@ -73,6 +77,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
73
77
|
redactHref: false,
|
|
74
78
|
directTextOnly: false,
|
|
75
79
|
documentRoot: false,
|
|
80
|
+
checkableValue: true,
|
|
76
81
|
};
|
|
77
82
|
/**
|
|
78
83
|
* Per-element memo for the facts several passes need. A tree walk asks for
|
|
@@ -262,11 +267,11 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
262
267
|
/** Computed style of any element, SVG included, or undefined in a document with no view. */
|
|
263
268
|
const styleOf = (el) => el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
264
269
|
/** A walk from the top, with the elements that may not contribute again already visited. */
|
|
265
|
-
const nameWalk = (visited,
|
|
270
|
+
const nameWalk = (visited, accessible) => ({
|
|
266
271
|
inReference: false,
|
|
267
272
|
hiddenAllowed: false,
|
|
268
273
|
visited: new Set(visited),
|
|
269
|
-
|
|
274
|
+
accessible,
|
|
270
275
|
});
|
|
271
276
|
/**
|
|
272
277
|
* Private-use code points: icon-font glyphs (Font Awesome's `\f090`) that
|
|
@@ -415,21 +420,86 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
415
420
|
inReference: true,
|
|
416
421
|
hiddenAllowed: isReferenceHidden(target),
|
|
417
422
|
visited: walk.visited,
|
|
418
|
-
|
|
423
|
+
accessible: walk.accessible,
|
|
419
424
|
}));
|
|
420
425
|
}
|
|
421
426
|
if (contributions.join(' ') === '')
|
|
422
427
|
return null;
|
|
423
428
|
return contributions.map((text) => text.replace(/\s+/g, ' ').trim()).filter((name) => name !== '');
|
|
424
429
|
};
|
|
430
|
+
/**
|
|
431
|
+
* What an embedded control contributes to a name computed through another
|
|
432
|
+
* element (accname 2C), by the rules Playwright's role selector applies, so
|
|
433
|
+
* `<button>Flash the screen <input value="3"> times</button>` is the button
|
|
434
|
+
* "Flash the screen 3 times": a textbox or searchbox its current value (an
|
|
435
|
+
* editable element its text), a combobox or listbox its selected options'
|
|
436
|
+
* names (a native select with none selected its first option, a text field
|
|
437
|
+
* with none its value), and a range widget its `aria-valuetext`, else its
|
|
438
|
+
* `aria-valuenow`, else its `value` attribute. A menu contributes nothing,
|
|
439
|
+
* and neither does a secure field, whose value no name may carry. Null for
|
|
440
|
+
* any other element, and for one its own `aria-labelledby` names, which is
|
|
441
|
+
* named, not embedded.
|
|
442
|
+
*/
|
|
443
|
+
const embeddedControlNameOf = (el, walk) => {
|
|
444
|
+
const role = implicitRole(el);
|
|
445
|
+
if (role === null)
|
|
446
|
+
return null;
|
|
447
|
+
if (el.id !== '' && (el.getAttribute('aria-labelledby') ?? '').trim().split(/\s+/).indexOf(el.id) !== -1)
|
|
448
|
+
return null;
|
|
449
|
+
if (el.matches(options.secureFieldSelector))
|
|
450
|
+
return '';
|
|
451
|
+
if (role === 'textbox' || role === 'searchbox') {
|
|
452
|
+
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : el.textContent ?? '';
|
|
453
|
+
}
|
|
454
|
+
if (role === 'combobox' || role === 'listbox') {
|
|
455
|
+
let selected;
|
|
456
|
+
if (el instanceof HTMLSelectElement) {
|
|
457
|
+
selected = Array.from(el.selectedOptions);
|
|
458
|
+
if (selected.length === 0 && el.options.length > 0)
|
|
459
|
+
selected.push(el.options[0]);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
const listbox = role === 'combobox' ? ariaOwnedOf(el).find((owned) => implicitRole(owned) === 'listbox') : el;
|
|
463
|
+
selected = listbox === undefined
|
|
464
|
+
? []
|
|
465
|
+
: ariaOwnedOf(listbox).filter((owned) => owned.getAttribute('aria-selected') === 'true' && implicitRole(owned) === 'option');
|
|
466
|
+
}
|
|
467
|
+
if (selected.length === 0 && el instanceof HTMLInputElement)
|
|
468
|
+
return el.value;
|
|
469
|
+
return selected.map((option) => contentNameOf(option, styleOf(option), walk)).join(' ');
|
|
470
|
+
}
|
|
471
|
+
if (role === 'progressbar' || role === 'scrollbar' || role === 'slider' || role === 'spinbutton' || role === 'meter') {
|
|
472
|
+
if (el.hasAttribute('aria-valuetext'))
|
|
473
|
+
return el.getAttribute('aria-valuetext') ?? '';
|
|
474
|
+
if (el.hasAttribute('aria-valuenow'))
|
|
475
|
+
return el.getAttribute('aria-valuenow') ?? '';
|
|
476
|
+
return el.getAttribute('value') ?? '';
|
|
477
|
+
}
|
|
478
|
+
return role === 'menu' ? '' : null;
|
|
479
|
+
};
|
|
480
|
+
/**
|
|
481
|
+
* Every element under `el` and every element its `aria-owns` names, with
|
|
482
|
+
* theirs: what a composite widget owns, as Playwright's role selector
|
|
483
|
+
* collects a combobox's listbox and a listbox's options.
|
|
484
|
+
*/
|
|
485
|
+
const ariaOwnedOf = (el) => {
|
|
486
|
+
const owned = Array.from(el.querySelectorAll('*'));
|
|
487
|
+
for (const id of (el.getAttribute('aria-owns') ?? '').trim().split(/\s+/)) {
|
|
488
|
+
const target = id === '' ? null : referencedElementOf(el, id);
|
|
489
|
+
if (target !== null)
|
|
490
|
+
owned.push(target, ...Array.from(target.querySelectorAll('*')));
|
|
491
|
+
}
|
|
492
|
+
return owned;
|
|
493
|
+
};
|
|
425
494
|
/**
|
|
426
495
|
* What one element contributes to a name computed through another: as an
|
|
427
496
|
* aria-labelledby target (accname 2B) or as a descendant read for name from
|
|
428
|
-
* content (2F). Its own references, else its
|
|
429
|
-
* children, then its `title`. So
|
|
430
|
-
* the button "Search", an icon
|
|
497
|
+
* content (2F). Its own references, else its value as an embedded control,
|
|
498
|
+
* its `aria-label`, its `alt`, its children, then its `title`. So
|
|
499
|
+
* `<button><img alt="Search"></button>` is the button "Search", an icon
|
|
500
|
+
* whose only child is
|
|
431
501
|
* `<svg aria-labelledby="t"><title id="t">Close</title></svg>` is "Close",
|
|
432
|
-
* and a hidden `<span>` a button references still names it.
|
|
502
|
+
* and a hidden `<span>` a button references still names it. Any other form
|
|
433
503
|
* control's content is its value, not label text, so it contributes nothing.
|
|
434
504
|
* The title is the fallback only for empty content, untrimmed as Playwright
|
|
435
505
|
* reads it: the spaces a block-level child adds count, so a flex container's
|
|
@@ -445,6 +515,9 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
445
515
|
const referenced = referencedNamesOf(el, walk);
|
|
446
516
|
if (referenced !== null)
|
|
447
517
|
return referenced.join(' ');
|
|
518
|
+
const embedded = walk.accessible ? embeddedControlNameOf(el, walk) : null;
|
|
519
|
+
if (embedded !== null)
|
|
520
|
+
return embedded;
|
|
448
521
|
const ariaLabel = el.getAttribute('aria-label');
|
|
449
522
|
if (ariaLabel !== null && ariaLabel.trim() !== '')
|
|
450
523
|
return ariaLabel.trim();
|
|
@@ -469,7 +542,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
469
542
|
* `.next::after { content: " \2192" }` is the button "Go →".
|
|
470
543
|
*/
|
|
471
544
|
const childrenNameOf = (el, walk) => {
|
|
472
|
-
let out = walk.
|
|
545
|
+
let out = walk.accessible ? beforeContentOf(el) : '';
|
|
473
546
|
for (const child of contentChildrenOf(el)) {
|
|
474
547
|
if (child.nodeType === 3) {
|
|
475
548
|
out += child.nodeValue ?? '';
|
|
@@ -482,7 +555,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
482
555
|
const block = child.tagName === 'BR' || (style?.display ?? 'inline') !== 'inline';
|
|
483
556
|
out += block ? ` ${token} ` : token;
|
|
484
557
|
}
|
|
485
|
-
return walk.
|
|
558
|
+
return walk.accessible ? out + afterContentOf(el) : out;
|
|
486
559
|
};
|
|
487
560
|
/**
|
|
488
561
|
* The nodes a name from content reads under an element, in the order the
|
|
@@ -516,15 +589,17 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
516
589
|
* its descendants' contributions, hidden and aria-hidden subtrees dropped (a
|
|
517
590
|
* required-field marker, a decorative glyph), whitespace collapsed. The
|
|
518
591
|
* element's own attributes are `accessibleName`'s business; this reads what
|
|
519
|
-
* is inside it. `
|
|
520
|
-
* accessible name does and label text does not.
|
|
592
|
+
* is inside it. `accessible` reads CSS generated content and embedded
|
|
593
|
+
* controls' values too, as an accessible name does and label text does not.
|
|
594
|
+
* `named` is the element the text names when that is not `el` (a control
|
|
595
|
+
* read through its `<label>`), which never contributes to its own name.
|
|
521
596
|
*/
|
|
522
|
-
const nameTextOf = (el,
|
|
597
|
+
const nameTextOf = (el, accessible, named = el) => {
|
|
523
598
|
if (isNameHidden(el, styleOf(el)))
|
|
524
599
|
return '';
|
|
525
600
|
if (NAME_OPAQUE_TAGS.has(el.tagName))
|
|
526
601
|
return '';
|
|
527
|
-
return childrenNameOf(el, nameWalk([el],
|
|
602
|
+
return childrenNameOf(el, nameWalk([el, named], accessible)).replace(/\s+/g, ' ').trim();
|
|
528
603
|
};
|
|
529
604
|
const NAME_OPAQUE_TAGS = new Set(['TEXTAREA', 'SELECT', 'INPUT', 'SCRIPT', 'STYLE']);
|
|
530
605
|
/**
|
|
@@ -578,8 +653,14 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
578
653
|
const shown = labels.map(withoutGlyphs).filter((label) => label !== '');
|
|
579
654
|
return shown.length === 0 ? null : shown;
|
|
580
655
|
};
|
|
581
|
-
/**
|
|
656
|
+
/**
|
|
657
|
+
* Text owned directly by an element, excluding descendant elements. An
|
|
658
|
+
* element with `content-visibility: hidden` renders none of it, as its
|
|
659
|
+
* `innerText` reads empty, so it owns none.
|
|
660
|
+
*/
|
|
582
661
|
const directTextOf = memoized((el) => {
|
|
662
|
+
if (styleOf(el)?.getPropertyValue('content-visibility') === 'hidden')
|
|
663
|
+
return '';
|
|
583
664
|
let out = '';
|
|
584
665
|
for (const child of Array.from(el.childNodes)) {
|
|
585
666
|
if (child.nodeType === 3)
|
|
@@ -598,7 +679,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
598
679
|
const labels = associatedLabels(el);
|
|
599
680
|
if (labels.length > 0) {
|
|
600
681
|
const joined = labels
|
|
601
|
-
.map((label) => nameTextOf(label, true))
|
|
682
|
+
.map((label) => nameTextOf(label, true, el))
|
|
602
683
|
.join(' ')
|
|
603
684
|
.trim();
|
|
604
685
|
if (joined !== '')
|
|
@@ -752,25 +833,42 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
752
833
|
return false;
|
|
753
834
|
};
|
|
754
835
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
836
|
+
* True when an ancestor in the flat tree has `content-visibility: hidden`:
|
|
837
|
+
* it keeps its own box and skips rendering everything under it, slotted
|
|
838
|
+
* and shadow content included, as `checkVisibility()` reads it. Memoized,
|
|
839
|
+
* so siblings under one ancestor share the walk up.
|
|
840
|
+
*/
|
|
841
|
+
const isSkippedContent = memoized((el) => {
|
|
842
|
+
const parent = el.assignedSlot ?? parentOrHostOf(el);
|
|
843
|
+
if (parent === null)
|
|
844
|
+
return false;
|
|
845
|
+
return styleOf(parent)?.getPropertyValue('content-visibility') === 'hidden' || isSkippedContent(parent);
|
|
846
|
+
});
|
|
847
|
+
/**
|
|
848
|
+
* Render visibility, the one Playwright's `toBeVisible` and `toBeHidden`
|
|
849
|
+
* read, so a semantic `hidden` state and the platform's own filter agree:
|
|
757
850
|
* `display: none` or a `visibility` other than `visible` on any element (an
|
|
758
|
-
* SVG included), content a
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
851
|
+
* SVG included), content a `content-visibility: hidden` ancestor skips or a
|
|
852
|
+
* closed `<details>` folds away, and a box with no width or no height.
|
|
853
|
+
* `aria-hidden` is not in it: it removes a node from the accessibility tree
|
|
854
|
+
* (`hidesSubtree`, a role query) while the node still paints, as a spinner
|
|
855
|
+
* marked decorative does. `display: contents` generates no box of its own
|
|
856
|
+
* while every child still paints (Shopify's one-page checkout form is one),
|
|
857
|
+
* so such an element is shown when some child element or text is, and
|
|
858
|
+
* hidden when nothing under it is. Its text paints in its own `visibility`,
|
|
859
|
+
* which a range's box does not reflect, so text under a hidden one is
|
|
860
|
+
* hidden, where Playwright calls it visible.
|
|
763
861
|
*/
|
|
764
862
|
const isHidden = (el, style = styleOf(el)) => {
|
|
765
|
-
if (el.getAttribute('aria-hidden') === 'true')
|
|
766
|
-
return true;
|
|
767
863
|
if (style === undefined)
|
|
768
864
|
return true;
|
|
865
|
+
if (isSkippedContent(el))
|
|
866
|
+
return true;
|
|
769
867
|
if (style.display === 'contents') {
|
|
770
868
|
for (let child = el.firstChild; child !== null; child = child.nextSibling) {
|
|
771
869
|
if (child instanceof Element && !isHidden(child))
|
|
772
870
|
return false;
|
|
773
|
-
if (child.nodeType === Node.TEXT_NODE && isVisibleText(child))
|
|
871
|
+
if (child.nodeType === Node.TEXT_NODE && style.visibility === 'visible' && isVisibleText(child))
|
|
774
872
|
return false;
|
|
775
873
|
}
|
|
776
874
|
return true;
|
|
@@ -783,18 +881,20 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
783
881
|
return !(rect.width > 0 && rect.height > 0);
|
|
784
882
|
};
|
|
785
883
|
/**
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
* it
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
* is
|
|
884
|
+
* What takes the element and everything under it out of the tree walk: an
|
|
885
|
+
* `aria-hidden` or inert subtree, which the accessibility tree drops though
|
|
886
|
+
* it may paint (`isInert`), and `display: none` or a closed `<details>`
|
|
887
|
+
* body, which no descendant can undo. A `visibility` other than `visible`
|
|
888
|
+
* is not in it, since a descendant may set `visibility: visible` and paint
|
|
889
|
+
* again; nor is a box with no size: a zero-height `<html>` or wrapper still
|
|
890
|
+
* shows the fixed, absolute, and overflowing descendants laid out past its
|
|
891
|
+
* edges. The walk goes on through both and only the element itself stays
|
|
892
|
+
* unlisted.
|
|
792
893
|
*/
|
|
793
894
|
const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
|
|
794
895
|
isInert(el, style) ||
|
|
795
896
|
style === undefined ||
|
|
796
897
|
style.display === 'none' ||
|
|
797
|
-
(style.display !== 'contents' && style.visibility !== 'visible') ||
|
|
798
898
|
isInClosedDetails(el);
|
|
799
899
|
/**
|
|
800
900
|
* Inert content takes no input and is hidden from assistive technology, as
|
|
@@ -1034,9 +1134,13 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1034
1134
|
let checked = null;
|
|
1035
1135
|
let selectedState = null;
|
|
1036
1136
|
if (el instanceof HTMLInputElement) {
|
|
1037
|
-
|
|
1137
|
+
// A checkbox or radio submits its value attribute (`on` by default)
|
|
1138
|
+
// whatever its checked state, so a locator read reports both; the tree
|
|
1139
|
+
// drops that token as it drops an option's.
|
|
1140
|
+
const checkable = el.type === 'checkbox' || el.type === 'radio';
|
|
1141
|
+
if (checkable)
|
|
1038
1142
|
checked = el.checked;
|
|
1039
|
-
|
|
1143
|
+
if (!checkable || projection.checkableValue)
|
|
1040
1144
|
value = el.value;
|
|
1041
1145
|
}
|
|
1042
1146
|
else if (el instanceof HTMLTextAreaElement) {
|
|
@@ -1134,7 +1238,10 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1134
1238
|
const isDocumentRoot = projection.documentRoot && tag === 'html';
|
|
1135
1239
|
if (isDocumentRoot)
|
|
1136
1240
|
name = el.ownerDocument.title;
|
|
1137
|
-
|
|
1241
|
+
// No layout box at all (`display: none`, an empty `display: contents`) is
|
|
1242
|
+
// no rect, as Playwright's `boundingBox()` answers null; a box with no
|
|
1243
|
+
// size, or one `visibility: hidden` keeps, is still a rect.
|
|
1244
|
+
const rect = el.getClientRects().length === 0 ? null : el.getBoundingClientRect();
|
|
1138
1245
|
const role = isDocumentRoot ? 'document' : roleOf(el, tag);
|
|
1139
1246
|
let level = null;
|
|
1140
1247
|
if (role === 'heading') {
|
|
@@ -1168,11 +1275,13 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1168
1275
|
level,
|
|
1169
1276
|
attributes,
|
|
1170
1277
|
testId: el.getAttribute(options.testIdAttribute),
|
|
1171
|
-
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1278
|
+
rect: rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1172
1279
|
};
|
|
1173
1280
|
};
|
|
1174
1281
|
// The conditional return type resolves per call site; inside the body the
|
|
1175
|
-
// discriminant narrows the value but not the generic, hence the
|
|
1282
|
+
// discriminant narrows the value but not the generic, hence the casts.
|
|
1283
|
+
if (options.mode.kind === 'hidden')
|
|
1284
|
+
return isHidden(element);
|
|
1176
1285
|
if (options.mode.kind === 'node') {
|
|
1177
1286
|
return describe(element);
|
|
1178
1287
|
}
|
|
@@ -1280,6 +1389,9 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1280
1389
|
}
|
|
1281
1390
|
if (OPAQUE_TAGS.indexOf(tag) !== -1)
|
|
1282
1391
|
return;
|
|
1392
|
+
// The element keeps its box, and nothing under it renders (`isSkippedContent`).
|
|
1393
|
+
if (style?.getPropertyValue('content-visibility') === 'hidden')
|
|
1394
|
+
return;
|
|
1283
1395
|
for (const child of Array.from(el.children))
|
|
1284
1396
|
walk(child, nextParent);
|
|
1285
1397
|
// A shadow root is part of what the user sees, so it is part of what the
|
package/dist/index.d.ts
CHANGED
|
@@ -8,10 +8,11 @@ import type { Browser } from './browser.ts';
|
|
|
8
8
|
export { web, surfaceOf } from './engine.ts';
|
|
9
9
|
export type { PlaywrightLiveSurface } from './engine.ts';
|
|
10
10
|
export type { WebBasicAuth, WebConnectOptions, WebOptions, WebScreencastOptions } from './surface.ts';
|
|
11
|
+
export type { WebInitScript } from './init-scripts.ts';
|
|
11
12
|
export type { BrowserDownloadContext, BrowserLease, BrowserProvider, BrowserProviderDownloads, BrowserProviderScope, BrowserReleaseContext, BrowserRequest, } from './provider.ts';
|
|
12
13
|
export type { BrowserName } from './browser-connection.ts';
|
|
13
14
|
export type { Dialog, DialogHandler } from './dialogs.ts';
|
|
14
|
-
export type { Browser, BrowserExpectation, Cookie, CookieFields, FrameScreen, RouteFulfillResponse, WebResponse, WebRoute, } from './browser.ts';
|
|
15
|
+
export type { Browser, BrowserExpectation, Cookie, CookieFields, FrameScreen, RouteContinueOverrides, RouteFulfillResponse, WebResponse, WebRoute, } from './browser.ts';
|
|
15
16
|
/**
|
|
16
17
|
* `test` typed with this engine's contributed `browser` fixture. The same
|
|
17
18
|
* runtime `test` as `e2e`'s; only the fixture types differ.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One init script: a string of JavaScript source, a `{ path }` to a file of
|
|
3
|
+
* it relative to the project root, or a function serialized into the page,
|
|
4
|
+
* which can close over nothing from the test process.
|
|
5
|
+
*/
|
|
6
|
+
export type WebInitScript = string | {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
} | (() => unknown);
|
|
9
|
+
/** Refuses, at config load, a `web({ initScripts })` value that is not a list of scripts. */
|
|
10
|
+
export declare function validateInitScripts(scripts: unknown): void;
|
|
11
|
+
/** The configured `initScripts`, read into page source once per process and handed to each attempt as the start of its own list. */
|
|
12
|
+
export declare class ConfiguredInitScripts {
|
|
13
|
+
private readonly scripts;
|
|
14
|
+
private sources;
|
|
15
|
+
constructor(scripts: readonly WebInitScript[]);
|
|
16
|
+
/** Reads every script, a `path` against the project root; a file that cannot be read is `INVALID_CONFIG`. */
|
|
17
|
+
load(projectRoot: string): Promise<void>;
|
|
18
|
+
/** A new attempt's init scripts: the configured ones, to which `browser.addInitScript` appends. */
|
|
19
|
+
forAttempt(): string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validates one script `browser.addInitScript` was given and reads it into
|
|
23
|
+
* page source, a `path` through `resolvePath`. Only a function takes an
|
|
24
|
+
* argument, as in Playwright.
|
|
25
|
+
*/
|
|
26
|
+
export declare function testInitScriptSource(script: unknown, argument: {
|
|
27
|
+
readonly arg: unknown;
|
|
28
|
+
} | undefined, resolvePath: (file: string) => string): Promise<string>;
|
|
29
|
+
/** The step label of a script: the file a `path` names, else its kind, never its source. */
|
|
30
|
+
export declare function initScriptLabel(script: unknown): string;
|