@e2e-dev/web 0.12.0 → 0.14.0-nightly-20261008205824
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/.build.json +1 -1
- package/dist/actions.d.ts +10 -3
- package/dist/actions.js +79 -33
- package/dist/app-log.d.ts +20 -0
- package/dist/app-log.js +127 -0
- package/dist/attempt-session.d.ts +5 -19
- package/dist/attempt-session.js +19 -91
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +59 -47
- package/dist/dialogs.js +5 -0
- package/dist/engine.js +0 -2
- package/dist/evaluation.d.ts +8 -3
- package/dist/evaluation.js +38 -4
- package/dist/in-page/read-semantics.js +155 -16
- package/dist/init-scripts.js +2 -2
- package/dist/install.d.ts +6 -0
- package/dist/install.js +44 -14
- package/dist/observation-capture.d.ts +1 -2
- package/dist/observation.d.ts +16 -0
- package/dist/observation.js +47 -12
- package/dist/operation-budget.d.ts +14 -0
- package/dist/operation-budget.js +31 -0
- package/dist/provider-video.d.ts +0 -2
- package/dist/provider-video.js +0 -2
- package/dist/support.d.ts +14 -10
- package/dist/support.js +40 -19
- package/dist/surface.d.ts +29 -14
- package/dist/surface.js +117 -44
- package/dist/trace-feed.d.ts +40 -0
- package/dist/trace-feed.js +93 -0
- package/dist/video.d.ts +0 -4
- package/dist/video.js +23 -5
- package/package.json +4 -4
package/dist/browser.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* says which URLs a test may open.
|
|
13
13
|
*/
|
|
14
14
|
import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
|
|
15
|
+
import { classifyInputError } from './actions.js';
|
|
15
16
|
import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
|
|
16
17
|
import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
|
|
17
|
-
import { compileEvaluation } from './evaluation.js';
|
|
18
|
+
import { compileEvaluation, pageFunctionSource } from './evaluation.js';
|
|
18
19
|
import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
|
|
19
20
|
import { lowercaseNames } from './protected-app.js';
|
|
20
21
|
import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
|
|
@@ -62,31 +63,33 @@ export function createBrowserFixture(surface, context) {
|
|
|
62
63
|
};
|
|
63
64
|
/** An assertion-style budget: the given timeout or the assertion timeout, clamped to the test. */
|
|
64
65
|
const deadlineFor = (timeout) => new Deadline(context.operation(timeout ?? context.timeouts.assertion).timeoutMs);
|
|
65
|
-
|
|
66
|
-
const
|
|
66
|
+
/** The page URL, read within the action timeout or, for a matcher poll, within the poll's deadline. */
|
|
67
|
+
const currentUrl = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'url', async () => surface.requirePage().url());
|
|
68
|
+
/** The page title, read within the action timeout or, for a matcher poll, within the poll's deadline. */
|
|
69
|
+
const currentTitle = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'title', () => surface.requirePage().title());
|
|
67
70
|
const expectation = createBrowserExpectation({ currentUrl, currentTitle, baseHref, deadlineFor, context });
|
|
68
71
|
const browser = {
|
|
69
72
|
goto(url, options) {
|
|
70
73
|
const resolved = context.app.resolveUrl(url);
|
|
71
|
-
return navigation(options, async (
|
|
74
|
+
return navigation(options, (operation) => surface.navigating(async () => {
|
|
72
75
|
const page = await surface.ensurePage();
|
|
73
76
|
await page.goto(resolved, {
|
|
74
77
|
waitUntil: options?.waitUntil ?? 'load',
|
|
75
78
|
timeout: operation.timeoutMs,
|
|
76
79
|
});
|
|
77
|
-
});
|
|
80
|
+
}));
|
|
78
81
|
},
|
|
79
|
-
reload: (options) => navigation(options, async (
|
|
82
|
+
reload: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
80
83
|
await surface.requirePage().reload({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
81
|
-
}),
|
|
82
|
-
back: (options) => navigation(options, async (
|
|
84
|
+
})),
|
|
85
|
+
back: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
83
86
|
await surface.requirePage().goBack({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
84
|
-
}),
|
|
85
|
-
forward: (options) => navigation(options, async (
|
|
87
|
+
})),
|
|
88
|
+
forward: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
86
89
|
await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
87
|
-
}),
|
|
88
|
-
url: currentUrl,
|
|
89
|
-
title: currentTitle,
|
|
90
|
+
})),
|
|
91
|
+
url: () => currentUrl(),
|
|
92
|
+
title: () => currentTitle(),
|
|
90
93
|
// The same poll as `expect(browser).toHaveURL`, exposed as a wait.
|
|
91
94
|
waitForURL: (url, options) => {
|
|
92
95
|
rejectUnknownOptions('browser.waitForURL', options, ['timeout']);
|
|
@@ -95,7 +98,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
95
98
|
locator: (selector) => context.locator({ kind: 'selector', selector: requireSelector('browser.locator', selector) }),
|
|
96
99
|
frameLocator: (selector) => frameScreen(context, [requireSelector('browser.frameLocator', selector)]),
|
|
97
100
|
async evaluate(fn, arg) {
|
|
98
|
-
const source = typeof fn === 'string' ? fn : fn
|
|
101
|
+
const source = typeof fn === 'string' ? fn : pageFunctionSource(fn);
|
|
99
102
|
validateJsonValue(arg, 'evaluate argument');
|
|
100
103
|
const evaluate = compileEvaluation(source, arg !== undefined);
|
|
101
104
|
// JSON safety is checked above; Playwright's recursive argument type cannot expand JsonValue.
|
|
@@ -329,17 +332,17 @@ export function createBrowserFixture(surface, context) {
|
|
|
329
332
|
return new TestError('ACTION_FAILED', `no download started within ${awaiting.waitMs}ms; the trigger resolved after ${awaiting.triggerMs}ms`, { cause });
|
|
330
333
|
}
|
|
331
334
|
return translated;
|
|
332
|
-
});
|
|
335
|
+
}, 'test-code');
|
|
333
336
|
},
|
|
334
337
|
keyboard: {
|
|
335
|
-
press: (key) => surface.guard(context.operation(), 'keyboard.press', () => surface.requirePage().keyboard.press(key)),
|
|
336
|
-
type: (text) => surface.guard(context.operation(), 'keyboard.type', () => surface.requirePage().keyboard.type(text)),
|
|
338
|
+
press: (key) => surface.guard(context.operation(), 'keyboard.press', () => surface.requirePage().keyboard.press(key), classifyInputError),
|
|
339
|
+
type: (text) => surface.guard(context.operation(), 'keyboard.type', () => surface.requirePage().keyboard.type(text), classifyInputError),
|
|
337
340
|
},
|
|
338
341
|
mouse: {
|
|
339
|
-
move: (x, y) => surface.guard(context.operation(), 'mouse.move', () => surface.requirePage().mouse.move(x, y)),
|
|
340
|
-
wheel: (deltaX, deltaY) => surface.guard(context.operation(), 'mouse.wheel', () => surface.requirePage().mouse.wheel(deltaX, deltaY)),
|
|
341
|
-
down: () => surface.guard(context.operation(), 'mouse.down', () => surface.requirePage().mouse.down()),
|
|
342
|
-
up: () => surface.guard(context.operation(), 'mouse.up', () => surface.requirePage().mouse.up()),
|
|
342
|
+
move: (x, y) => surface.guard(context.operation(), 'mouse.move', () => surface.requirePage().mouse.move(x, y), classifyInputError),
|
|
343
|
+
wheel: (deltaX, deltaY) => surface.guard(context.operation(), 'mouse.wheel', () => surface.requirePage().mouse.wheel(deltaX, deltaY), classifyInputError),
|
|
344
|
+
down: () => surface.guard(context.operation(), 'mouse.down', () => surface.requirePage().mouse.down(), classifyInputError),
|
|
345
|
+
up: () => surface.guard(context.operation(), 'mouse.up', () => surface.requirePage().mouse.up(), classifyInputError),
|
|
343
346
|
},
|
|
344
347
|
};
|
|
345
348
|
const action = { kind: 'resource' };
|
|
@@ -372,15 +375,26 @@ export function createBrowserFixture(surface, context) {
|
|
|
372
375
|
mouse: { move: action, wheel: action, down: action, up: action },
|
|
373
376
|
});
|
|
374
377
|
}
|
|
375
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* `expect(browser)` matchers: URL, title, and class polling against the
|
|
380
|
+
* assertion budget. Every read is bounded by the poll's deadline, so a page
|
|
381
|
+
* that stops answering ends the assertion at its own timeout, and the
|
|
382
|
+
* failure reports the last read instead of reading a hung page again.
|
|
383
|
+
*/
|
|
376
384
|
function createBrowserExpectation(deps, negated = false) {
|
|
377
|
-
const poll = async (api, label,
|
|
385
|
+
const poll = async (api, label, read, timeout) => {
|
|
386
|
+
const deadline = deps.deadlineFor(timeout);
|
|
387
|
+
// pollCondition calls onTimeout only after a read completed, so a sample is always there.
|
|
388
|
+
let last;
|
|
378
389
|
await pollCondition({
|
|
379
|
-
deadline
|
|
390
|
+
deadline,
|
|
380
391
|
signal: deps.context.signal,
|
|
381
392
|
negated,
|
|
382
|
-
evaluate:
|
|
383
|
-
|
|
393
|
+
evaluate: async () => {
|
|
394
|
+
last = await read(deadline);
|
|
395
|
+
return last.matches;
|
|
396
|
+
},
|
|
397
|
+
onTimeout: (cause) => new TestError('ASSERTION_FAILED', `expect.${negated ? 'not.' : ''}${api} failed\nexpected: ${negated ? 'not ' : ''}${label}\nobserved: ${last?.observed ?? 'nothing'}`, cause === undefined ? undefined : { cause }),
|
|
384
398
|
});
|
|
385
399
|
};
|
|
386
400
|
const validate = (api, options, keys = ['timeout']) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options, keys);
|
|
@@ -396,41 +410,39 @@ function createBrowserExpectation(deps, negated = false) {
|
|
|
396
410
|
}
|
|
397
411
|
const label = typeof expected === 'string' ? expected : String(expected);
|
|
398
412
|
const target = deps.baseHref();
|
|
399
|
-
return poll('toHaveURL', `URL ${label}${ignoreCase === true ? ' (ignoring case)' : ''}`, async () =>
|
|
413
|
+
return poll('toHaveURL', `URL ${label}${ignoreCase === true ? ' (ignoring case)' : ''}`, async (deadline) => {
|
|
414
|
+
const url = await deps.currentUrl(deadline);
|
|
415
|
+
return { matches: urlMatches(url, expected, target, ignoreCase), observed: `URL ${url}` };
|
|
416
|
+
}, options?.timeout);
|
|
400
417
|
},
|
|
401
418
|
toHaveTitle(expected, options) {
|
|
402
419
|
validate('toHaveTitle', options);
|
|
403
420
|
const pattern = toTextPattern(expected, { exact: true });
|
|
404
|
-
return poll('toHaveTitle', `title ${describePattern(pattern)}`, async () =>
|
|
421
|
+
return poll('toHaveTitle', `title ${describePattern(pattern)}`, async (deadline) => {
|
|
422
|
+
const title = await deps.currentTitle(deadline);
|
|
423
|
+
return { matches: matchesText(title, pattern), observed: `title ${JSON.stringify(title)}` };
|
|
424
|
+
}, options?.timeout);
|
|
405
425
|
},
|
|
406
426
|
toHaveClass(target, expected, options) {
|
|
407
427
|
validate('toHaveClass', options);
|
|
408
428
|
const pattern = toTextPattern(expected, { exact: true });
|
|
409
|
-
return poll('toHaveClass', `class ${describePattern(pattern)}`, async () => {
|
|
429
|
+
return poll('toHaveClass', `class ${describePattern(pattern)}`, async (deadline) => {
|
|
430
|
+
let value;
|
|
410
431
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return false;
|
|
414
|
-
const normalized = value.trim().split(/\s+/).join(' ');
|
|
415
|
-
return matchesText(normalized, pattern);
|
|
432
|
+
// A locator read takes the action timeout; the poll's deadline is the tighter bound.
|
|
433
|
+
value = await withTimeout(target.getAttribute('class'), deadline.remaining(), () => new EngineError('OPERATION_TIMEOUT', 'class read timed out', { retryable: false }));
|
|
416
434
|
}
|
|
417
435
|
catch (error) {
|
|
418
436
|
if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
|
|
419
|
-
return undefined;
|
|
420
|
-
throw error;
|
|
421
|
-
}
|
|
422
|
-
}, async () => {
|
|
423
|
-
try {
|
|
424
|
-
const value = await target.getAttribute('class');
|
|
425
|
-
if (value === null)
|
|
426
|
-
return 'no class attribute';
|
|
427
|
-
return `class ${JSON.stringify(value)}`;
|
|
428
|
-
}
|
|
429
|
-
catch (error) {
|
|
430
|
-
if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
|
|
431
|
-
return 'no node';
|
|
437
|
+
return { matches: undefined, observed: 'no node' };
|
|
432
438
|
throw error;
|
|
433
439
|
}
|
|
440
|
+
if (value === null)
|
|
441
|
+
return { matches: false, observed: 'no class attribute' };
|
|
442
|
+
return {
|
|
443
|
+
matches: matchesText(value.trim().split(/\s+/).join(' '), pattern),
|
|
444
|
+
observed: `class ${JSON.stringify(value)}`,
|
|
445
|
+
};
|
|
434
446
|
}, options?.timeout);
|
|
435
447
|
},
|
|
436
448
|
};
|
package/dist/dialogs.js
CHANGED
|
@@ -45,15 +45,18 @@ export class DialogRouter {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
let decided = false;
|
|
48
|
+
let answered = false;
|
|
48
49
|
const publicDialog = {
|
|
49
50
|
message: dialog.message(),
|
|
50
51
|
accept: async (text) => {
|
|
51
52
|
decided = true;
|
|
52
53
|
await dialog.accept(text);
|
|
54
|
+
answered = true;
|
|
53
55
|
},
|
|
54
56
|
dismiss: async () => {
|
|
55
57
|
decided = true;
|
|
56
58
|
await dialog.dismiss();
|
|
59
|
+
answered = true;
|
|
57
60
|
},
|
|
58
61
|
};
|
|
59
62
|
try {
|
|
@@ -81,6 +84,8 @@ export class DialogRouter {
|
|
|
81
84
|
retryable: false,
|
|
82
85
|
cause,
|
|
83
86
|
}));
|
|
87
|
+
if (!answered)
|
|
88
|
+
await dialog.dismiss().catch(() => undefined);
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
}
|
package/dist/engine.js
CHANGED
|
@@ -104,8 +104,6 @@ export function web(options = {}) {
|
|
|
104
104
|
},
|
|
105
105
|
artifacts: {
|
|
106
106
|
screenshot: (label, operation) => surface.screenshot(label, operation),
|
|
107
|
-
startTrace: (operation) => surface.startTrace(operation),
|
|
108
|
-
stopTrace: (operation) => surface.stopTrace(operation),
|
|
109
107
|
startVideo: (operation) => surface.startVideo(operation),
|
|
110
108
|
stopVideo: (operation) => surface.stopVideo(operation),
|
|
111
109
|
},
|
package/dist/evaluation.d.ts
CHANGED
|
@@ -7,13 +7,18 @@ type EvaluationResult = {
|
|
|
7
7
|
};
|
|
8
8
|
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
9
9
|
export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
10
|
+
/** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
|
|
11
|
+
export declare function pageFunctionSource(fn: (...args: never[]) => unknown): string;
|
|
10
12
|
/**
|
|
11
13
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
14
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
15
|
+
* other value is the result.
|
|
12
16
|
*
|
|
13
17
|
* A function serialized with `toString()` carries whatever the loader compiled
|
|
14
|
-
* it to.
|
|
15
|
-
* binding in a module-scoped `__name(target, name)`
|
|
16
|
-
* such helper, so the same one is declared next to
|
|
18
|
+
* it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
|
|
19
|
+
* wraps every nested named binding in a module-scoped `__name(target, name)`
|
|
20
|
+
* helper; the page has no such helper, so the same one is declared next to
|
|
21
|
+
* the inlined source for as long as this engine supports those runners.
|
|
17
22
|
*/
|
|
18
23
|
export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
|
|
19
24
|
export {};
|
package/dist/evaluation.js
CHANGED
|
@@ -3,13 +3,47 @@ import { TestError } from 'e2e/engine';
|
|
|
3
3
|
import { message } from './support.js';
|
|
4
4
|
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
5
5
|
export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
6
|
+
/** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
|
|
7
|
+
export function pageFunctionSource(fn) {
|
|
8
|
+
const source = fn.toString().trim();
|
|
9
|
+
const parse = (text) => new Function(`return (${text}\n);`);
|
|
10
|
+
try {
|
|
11
|
+
parse(source);
|
|
12
|
+
return source;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Method toString() omits the function keyword; an arrow or function expression already parsed above.
|
|
16
|
+
const ordinary = `function ${source}`;
|
|
17
|
+
let expression;
|
|
18
|
+
try {
|
|
19
|
+
parse(ordinary);
|
|
20
|
+
expression = ordinary;
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
if (!/^async\b/.test(source)) {
|
|
24
|
+
throw new TestError('EVALUATE_FAILED', 'page function cannot be serialized', { cause });
|
|
25
|
+
}
|
|
26
|
+
expression = `async function ${source.slice('async'.length)}`;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
parse(expression);
|
|
30
|
+
return expression;
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new TestError('EVALUATE_FAILED', 'page function cannot be serialized', { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
6
37
|
/**
|
|
7
38
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
39
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
40
|
+
* other value is the result.
|
|
8
41
|
*
|
|
9
42
|
* A function serialized with `toString()` carries whatever the loader compiled
|
|
10
|
-
* it to.
|
|
11
|
-
* binding in a module-scoped `__name(target, name)`
|
|
12
|
-
* such helper, so the same one is declared next to
|
|
43
|
+
* it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
|
|
44
|
+
* wraps every nested named binding in a module-scoped `__name(target, name)`
|
|
45
|
+
* helper; the page has no such helper, so the same one is declared next to
|
|
46
|
+
* the inlined source for as long as this engine supports those runners.
|
|
13
47
|
*/
|
|
14
48
|
export function compileEvaluation(source, hasArgument) {
|
|
15
49
|
try {
|
|
@@ -17,7 +51,7 @@ export function compileEvaluation(source, hasArgument) {
|
|
|
17
51
|
return (async () => {
|
|
18
52
|
${KEEP_NAMES_HELPER}
|
|
19
53
|
try {
|
|
20
|
-
return { ok: true, value: await (
|
|
54
|
+
return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
|
|
21
55
|
} catch (cause) {
|
|
22
56
|
let message;
|
|
23
57
|
try {
|
|
@@ -54,11 +54,11 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
54
54
|
const PLACEHOLDER_NAMED_INPUT_TYPES = ['text', 'password', 'number', 'search', 'tel', 'email', 'url'];
|
|
55
55
|
/**
|
|
56
56
|
* How the active mode projects one node, expressed as data so `describe`
|
|
57
|
-
* stays branch-free. Tree mode is the model-bound projection: bounded
|
|
58
|
-
* a bounded attribute projection, hrefs reduced to
|
|
59
|
-
* document named by its title, and no value on a
|
|
60
|
-
* value attribute is an app token its checked
|
|
61
|
-
* than. Node mode is the full locator-read surface.
|
|
57
|
+
* stays branch-free. Tree mode is the model-bound projection: bounded line
|
|
58
|
+
* text (`lineTextOf`), a bounded attribute projection, hrefs reduced to
|
|
59
|
+
* origin+path, the root document named by its title, and no value on a
|
|
60
|
+
* checkbox or radio, whose value attribute is an app token its checked
|
|
61
|
+
* state already says more than. Node mode is the full locator-read surface.
|
|
62
62
|
*/
|
|
63
63
|
const projection = options.mode.kind === 'tree'
|
|
64
64
|
? {
|
|
@@ -66,7 +66,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
66
66
|
textLimit: options.mode.textLimit,
|
|
67
67
|
nameLimit: options.mode.nameLimit,
|
|
68
68
|
redactHref: true,
|
|
69
|
-
|
|
69
|
+
lineText: true,
|
|
70
70
|
documentRoot: true,
|
|
71
71
|
checkableValue: false,
|
|
72
72
|
}
|
|
@@ -75,7 +75,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
75
75
|
textLimit: null,
|
|
76
76
|
nameLimit: null,
|
|
77
77
|
redactHref: false,
|
|
78
|
-
|
|
78
|
+
lineText: false,
|
|
79
79
|
documentRoot: false,
|
|
80
80
|
checkableValue: true,
|
|
81
81
|
};
|
|
@@ -111,7 +111,15 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
111
111
|
* HTML-AAM scopes it to sectioning content and to the ARIA roles that stand
|
|
112
112
|
* for it, so a `<div role="article">` scopes it like an `<article>`.
|
|
113
113
|
*/
|
|
114
|
-
const isPageLevel = (el) =>
|
|
114
|
+
const isPageLevel = (el) => {
|
|
115
|
+
for (let candidate = el; candidate !== null;) {
|
|
116
|
+
if (candidate.closest(SECTIONING_SCOPE) !== null)
|
|
117
|
+
return false;
|
|
118
|
+
const root = candidate.getRootNode();
|
|
119
|
+
candidate = root instanceof ShadowRoot ? root.host : null;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
115
123
|
/**
|
|
116
124
|
* The root of a contenteditable region: editable itself, under a parent that
|
|
117
125
|
* is not. A rich-text editor (ProseMirror, TipTap, Lexical, Slate) renders
|
|
@@ -219,7 +227,21 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
219
227
|
}
|
|
220
228
|
case 'th': {
|
|
221
229
|
const scope = (el.getAttribute('scope') ?? '').toLowerCase();
|
|
222
|
-
|
|
230
|
+
if (scope === 'row' || scope === 'rowgroup')
|
|
231
|
+
return 'rowheader';
|
|
232
|
+
if (scope === 'col' || scope === 'colgroup')
|
|
233
|
+
return 'columnheader';
|
|
234
|
+
const previous = el.previousElementSibling;
|
|
235
|
+
const next = el.nextElementSibling;
|
|
236
|
+
if (previous === null && next === null) {
|
|
237
|
+
const row = el.parentElement;
|
|
238
|
+
const table = row?.tagName.toLowerCase() === 'tr' ? row.closest('table') : null;
|
|
239
|
+
return table !== null && table.rows.length <= 1 ? null : 'columnheader';
|
|
240
|
+
}
|
|
241
|
+
if (previous?.tagName.toLowerCase() === 'th' && next?.tagName.toLowerCase() === 'th')
|
|
242
|
+
return 'columnheader';
|
|
243
|
+
const hasDataNeighbor = [previous, next].some((cell) => cell?.tagName.toLowerCase() === 'td' && ((cell.textContent ?? '').trim() !== '' || cell.children.length > 0));
|
|
244
|
+
return hasDataNeighbor ? 'rowheader' : 'columnheader';
|
|
223
245
|
}
|
|
224
246
|
case 'dialog':
|
|
225
247
|
return 'dialog';
|
|
@@ -354,7 +376,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
354
376
|
/** An element's `::after` contribution, once per read. */
|
|
355
377
|
const afterContentOf = memoized((el) => generatedContentOf(el, '::after'));
|
|
356
378
|
/** True for a subtree the name computation drops: aria-hidden, or hidden by style as innerText leaves it out. */
|
|
357
|
-
const isNameHidden = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
|
|
379
|
+
const isNameHidden = (el, style) => (el.getAttribute('aria-hidden') ?? '').toLowerCase() === 'true' ||
|
|
358
380
|
(style !== undefined && (style.display === 'none' || style.visibility === 'hidden'));
|
|
359
381
|
/** `alt` of an element HTML-AAM names by it: an `<img>` or an `<input type="image">`. */
|
|
360
382
|
const altOf = (el) => {
|
|
@@ -385,7 +407,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
385
407
|
* under an `aria-hidden` ancestor, which excludes it from the tree as
|
|
386
408
|
* surely as its own attribute would.
|
|
387
409
|
*/
|
|
388
|
-
const isReferenceHidden = (el) => isHidden(el) || el.closest('[aria-hidden="true"]') !== null;
|
|
410
|
+
const isReferenceHidden = (el) => isHidden(el) || el.closest('[aria-hidden="true" i]') !== null;
|
|
389
411
|
/**
|
|
390
412
|
* The element an id names for `el`, looked up in `el`'s own tree: an IDREF
|
|
391
413
|
* resolves in the tree scope it is written in, so a shadow tree's ids are
|
|
@@ -668,6 +690,121 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
668
690
|
}
|
|
669
691
|
return out.replace(/\s+/g, ' ').trim();
|
|
670
692
|
});
|
|
693
|
+
/**
|
|
694
|
+
* Elements whose content is not text in a line: a form control's options
|
|
695
|
+
* or value, a picture, a frame, a drawn surface. Like a line break, each
|
|
696
|
+
* separates the words around it.
|
|
697
|
+
*/
|
|
698
|
+
const LINE_BREAKING_TAGS = [...SKIP_TAGS, ...OPAQUE_TAGS, 'iframe', 'img', 'input', 'select', 'textarea', 'br'];
|
|
699
|
+
/**
|
|
700
|
+
* True when an element's text flows in its parent's line: a visible
|
|
701
|
+
* inline-level box (`inline`, `inline-block`, `ruby`), or `display:
|
|
702
|
+
* contents`, whose children lay out as the parent's own. Visible as the
|
|
703
|
+
* walk reads it (`isHidden`): a box with no size shows no words. A slot
|
|
704
|
+
* has no box and shows the nodes assigned to it, so only its visibility
|
|
705
|
+
* counts: a hidden one hides the text they inherit it with, and a child
|
|
706
|
+
* that shows itself again is listed on its own.
|
|
707
|
+
*/
|
|
708
|
+
const flowsInLine = memoized((el) => {
|
|
709
|
+
if (LINE_BREAKING_TAGS.indexOf(el.tagName.toLowerCase()) !== -1 || isEditingHost(el))
|
|
710
|
+
return false;
|
|
711
|
+
const style = styleOf(el);
|
|
712
|
+
if (style === undefined || hidesSubtree(el, style))
|
|
713
|
+
return false;
|
|
714
|
+
if (el instanceof HTMLSlotElement ? style.visibility !== 'visible' : isHidden(el, style))
|
|
715
|
+
return false;
|
|
716
|
+
return isInlineLevel(style);
|
|
717
|
+
});
|
|
718
|
+
/** True for a box laid out in its parent's line, or for `display: contents`, which lays out none of its own. */
|
|
719
|
+
const isInlineLevel = (style) => style.display.startsWith('inline') || style.display.startsWith('ruby') || style.display === 'contents';
|
|
720
|
+
/**
|
|
721
|
+
* The nodes an element renders in its place, as the walk reaches them: a
|
|
722
|
+
* shadow host's shadow tree (a closed one through the record), a slot's
|
|
723
|
+
* assigned nodes or else its fallback content, any other element's children.
|
|
724
|
+
*/
|
|
725
|
+
const renderedChildrenOf = (el) => {
|
|
726
|
+
const shadow = shadowRootOf(el);
|
|
727
|
+
if (shadow !== null)
|
|
728
|
+
return Array.from(shadow.childNodes);
|
|
729
|
+
if (el instanceof HTMLSlotElement) {
|
|
730
|
+
const assigned = el.assignedNodes();
|
|
731
|
+
if (assigned.length > 0)
|
|
732
|
+
return assigned;
|
|
733
|
+
}
|
|
734
|
+
return Array.from(el.childNodes);
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* An element's rendered text with each inline descendant's text in place.
|
|
738
|
+
* Anything else in the line reads as a space, except a hidden inline
|
|
739
|
+
* element (a `<wbr>`, a zero-size span), which shows nothing.
|
|
740
|
+
*/
|
|
741
|
+
const lineRunOf = (el) => {
|
|
742
|
+
let out = '';
|
|
743
|
+
for (const child of renderedChildrenOf(el)) {
|
|
744
|
+
if (child.nodeType === 3)
|
|
745
|
+
out += child.nodeValue ?? '';
|
|
746
|
+
if (child.nodeType !== 1)
|
|
747
|
+
continue;
|
|
748
|
+
const inner = child;
|
|
749
|
+
if (flowsInLine(inner)) {
|
|
750
|
+
out += lineRunOf(inner);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const style = styleOf(inner);
|
|
754
|
+
if (style === undefined || hidesSubtree(inner, style))
|
|
755
|
+
continue;
|
|
756
|
+
const breaking = LINE_BREAKING_TAGS.indexOf(inner.tagName.toLowerCase()) !== -1;
|
|
757
|
+
if (breaking || !isInlineLevel(style) || !isHidden(inner, style))
|
|
758
|
+
out += ' ';
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
};
|
|
762
|
+
/**
|
|
763
|
+
* The text an element owns, read as a person reads its line: its own text
|
|
764
|
+
* with every inline descendant's text in place, so `<p>Read our <a>privacy
|
|
765
|
+
* policy</a> for details.</p>` reads as the whole sentence while the link
|
|
766
|
+
* stays a node of its own. Block-level descendants are nodes of their own
|
|
767
|
+
* and only separate words. An element with no text of its own owns none: a
|
|
768
|
+
* wrapper of elements leaves them to say what they say (`ownsLine`).
|
|
769
|
+
*/
|
|
770
|
+
const lineTextOf = memoized((el) => ownsLine(el) ? lineRunOf(el).replace(/\s+/g, ' ').trim() : '');
|
|
771
|
+
/**
|
|
772
|
+
* True when an element reads its own line: it has text of its own, or it
|
|
773
|
+
* offers an action inside an ancestor's line, where its words are what a
|
|
774
|
+
* person aims at.
|
|
775
|
+
*/
|
|
776
|
+
const ownsLine = (el) => directTextOf(el) !== '' || (offersAction(el) && isReadInLine(el));
|
|
777
|
+
/**
|
|
778
|
+
* True when an element with no role still says it can be acted on: it is
|
|
779
|
+
* focusable through `tabindex`, it has a click handler set as an attribute
|
|
780
|
+
* or a property (`onclick`), or it is an `<a>` with no `href`, which an app
|
|
781
|
+
* wires up by script. Such an element stays listed inside a line, so it
|
|
782
|
+
* keeps an id to act on. A handler added with `addEventListener` leaves
|
|
783
|
+
* nothing the page can read.
|
|
784
|
+
*/
|
|
785
|
+
const offersAction = memoized((el) => {
|
|
786
|
+
const tabindex = el.getAttribute('tabindex');
|
|
787
|
+
if (tabindex !== null && /^\s*[-+]?\d/.test(tabindex))
|
|
788
|
+
return true;
|
|
789
|
+
if (el.tagName.toLowerCase() === 'a' || el.hasAttribute('onclick'))
|
|
790
|
+
return true;
|
|
791
|
+
return el instanceof HTMLElement && el.onclick !== null;
|
|
792
|
+
});
|
|
793
|
+
/**
|
|
794
|
+
* True when an element's text is already read in an ancestor's line
|
|
795
|
+
* (`lineTextOf`), so the element has nothing to add on text alone. A line
|
|
796
|
+
* longer than the projection's text bound is cut, so its inline
|
|
797
|
+
* descendants stay listed and nothing past the cut is lost.
|
|
798
|
+
*/
|
|
799
|
+
const isReadInLine = memoized((el) => {
|
|
800
|
+
const parent = el.assignedSlot ?? parentOrHostOf(el);
|
|
801
|
+
if (parent === null || !flowsInLine(el))
|
|
802
|
+
return false;
|
|
803
|
+
if (!ownsLine(parent))
|
|
804
|
+
return isReadInLine(parent);
|
|
805
|
+
const fits = projection.textLimit === null || lineTextOf(parent).length <= projection.textLimit;
|
|
806
|
+
return fits && !isHidden(parent);
|
|
807
|
+
});
|
|
671
808
|
const accessibleName = memoized((el) => {
|
|
672
809
|
// accname reads a labelledby reference (2B) before the element's own aria-label (2C).
|
|
673
810
|
const referenced = referencedNamesOf(el, nameWalk([], true));
|
|
@@ -891,7 +1028,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
891
1028
|
* edges. The walk goes on through both and only the element itself stays
|
|
892
1029
|
* unlisted.
|
|
893
1030
|
*/
|
|
894
|
-
const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
|
|
1031
|
+
const hidesSubtree = (el, style) => (el.getAttribute('aria-hidden') ?? '').toLowerCase() === 'true' ||
|
|
895
1032
|
isInert(el, style) ||
|
|
896
1033
|
style === undefined ||
|
|
897
1034
|
style.display === 'none' ||
|
|
@@ -1180,7 +1317,7 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1180
1317
|
if (selectedState === null) {
|
|
1181
1318
|
const ariaSelected = el.getAttribute('aria-selected');
|
|
1182
1319
|
if (ariaSelected !== null)
|
|
1183
|
-
selectedState = ariaSelected === 'true';
|
|
1320
|
+
selectedState = ariaSelected.toLowerCase() === 'true';
|
|
1184
1321
|
}
|
|
1185
1322
|
const disabled = isDisabled(el);
|
|
1186
1323
|
const ariaExpanded = el.getAttribute('aria-expanded');
|
|
@@ -1224,8 +1361,8 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1224
1361
|
let text;
|
|
1225
1362
|
if (secure)
|
|
1226
1363
|
text = '';
|
|
1227
|
-
else if (projection.
|
|
1228
|
-
text =
|
|
1364
|
+
else if (projection.lineText)
|
|
1365
|
+
text = lineTextOf(el);
|
|
1229
1366
|
else
|
|
1230
1367
|
text = textOf(el);
|
|
1231
1368
|
if (projection.textLimit !== null)
|
|
@@ -1320,7 +1457,9 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
1320
1457
|
return true;
|
|
1321
1458
|
if (accessibleName(el) !== null)
|
|
1322
1459
|
return true;
|
|
1323
|
-
|
|
1460
|
+
if (offersAction(el) && isReadInLine(el))
|
|
1461
|
+
return lineTextOf(el) !== '';
|
|
1462
|
+
return directTextOf(el) !== '' && !isReadInLine(el);
|
|
1324
1463
|
};
|
|
1325
1464
|
const walk = (el, parent) => {
|
|
1326
1465
|
if (truncated)
|
package/dist/init-scripts.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { ConfigurationError, TestError, validateJsonValue } from 'e2e/engine';
|
|
10
|
-
import { KEEP_NAMES_HELPER } from './evaluation.js';
|
|
10
|
+
import { KEEP_NAMES_HELPER, pageFunctionSource } from './evaluation.js';
|
|
11
11
|
import { message } from './support.js';
|
|
12
12
|
/** Refuses, at config load, a `web({ initScripts })` value that is not a list of scripts. */
|
|
13
13
|
export function validateInitScripts(scripts) {
|
|
@@ -93,7 +93,7 @@ async function initScriptSource(script, arg, resolvePath) {
|
|
|
93
93
|
if (typeof script === 'string')
|
|
94
94
|
return script;
|
|
95
95
|
if (typeof script === 'function') {
|
|
96
|
-
return `(() => {\n${KEEP_NAMES_HELPER}\n(${script
|
|
96
|
+
return `(() => {\n${KEEP_NAMES_HELPER}\n(${pageFunctionSource(script)}\n)(${arg === undefined ? '' : `JSON.parse(${JSON.stringify(JSON.stringify(arg))})`});\n})();`;
|
|
97
97
|
}
|
|
98
98
|
const file = resolvePath(script.path);
|
|
99
99
|
try {
|
package/dist/install.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface InstallContext {
|
|
|
15
15
|
readonly signal?: AbortSignal | undefined;
|
|
16
16
|
/** Environment the installer runs with: the run's, so it fills the cache the workers will launch from. */
|
|
17
17
|
readonly env: NodeJS.ProcessEnv;
|
|
18
|
+
/** Whether the run launches a window, which decides the chromium build it needs. */
|
|
19
|
+
readonly headed: boolean;
|
|
18
20
|
}
|
|
19
21
|
export interface EnsureBrowsersOptions {
|
|
20
22
|
/**
|
|
@@ -34,7 +36,11 @@ export interface EnsureBrowsersOptions {
|
|
|
34
36
|
readonly signal?: AbortSignal;
|
|
35
37
|
/** The run's environment; defaults to this process's. */
|
|
36
38
|
readonly env?: NodeJS.ProcessEnv;
|
|
39
|
+
/** Whether the run launches a window, which decides the chromium build it needs. Defaults to false. */
|
|
40
|
+
readonly headed?: boolean;
|
|
37
41
|
}
|
|
42
|
+
/** The `playwright install` arguments for `names`: `--only-shell` on a headless chromium run. */
|
|
43
|
+
export declare function installArgs(names: readonly BrowserName[], headed: boolean): string[];
|
|
38
44
|
/**
|
|
39
45
|
* Ensures the given Playwright browsers are installed, downloading any
|
|
40
46
|
* missing ones via `playwright-core install`. Called from the engine's `prepare`,
|