@e2e-dev/web 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +8 -2
- package/dist/actions.js +34 -5
- package/dist/attempt-session.d.ts +3 -2
- package/dist/attempt-session.js +17 -14
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +49 -37
- package/dist/evaluation.d.ts +6 -3
- package/dist/evaluation.js +7 -4
- package/dist/in-page/read-semantics.js +15 -1
- package/dist/install.d.ts +6 -0
- package/dist/install.js +44 -14
- package/dist/operation-budget.d.ts +14 -0
- package/dist/operation-budget.js +31 -0
- package/dist/support.d.ts +14 -10
- package/dist/support.js +40 -19
- package/dist/surface.d.ts +6 -3
- package/dist/surface.js +16 -14
- package/package.json +4 -4
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.13.0"}
|
package/dist/actions.d.ts
CHANGED
|
@@ -13,10 +13,16 @@ export declare function dispatchLocatorAction(target: ActionTarget, action: Loca
|
|
|
13
13
|
* behind it: no actionability wait, because there is no element to wait on,
|
|
14
14
|
* and the page decides what the gesture lands on, as it does for a person.
|
|
15
15
|
*/
|
|
16
|
-
export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction): Promise<void>;
|
|
16
|
+
export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction, signal?: AbortSignal): Promise<void>;
|
|
17
|
+
/** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
|
|
18
|
+
export declare function classifyInputError(cause: unknown, label: string): Error;
|
|
17
19
|
/**
|
|
18
20
|
* Classifies a failed action onto the error contract (see the table above
|
|
19
21
|
* `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
|
|
20
|
-
* committed, or an engine fault.
|
|
22
|
+
* committed, or an engine fault. A strict mode violation is a located match
|
|
23
|
+
* that turned ambiguous: stale like a detached one before any input, so the
|
|
24
|
+
* runner locates again and reports the ambiguity or acts on the one match
|
|
25
|
+
* left; possibly committed once input went out, as the second side of a drag
|
|
26
|
+
* resolves after the press.
|
|
21
27
|
*/
|
|
22
28
|
export declare function classifyActionError(rawCause: unknown, action: LocatorAction): Error;
|
package/dist/actions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Locator action dispatch for the Playwright engine. */
|
|
2
2
|
import { EngineError } from 'e2e/engine';
|
|
3
|
-
import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, } from './support.js';
|
|
3
|
+
import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, translatePwError, } from './support.js';
|
|
4
4
|
/** How long a long press holds the button when the action names no duration. */
|
|
5
5
|
const DEFAULT_LONG_PRESS_MS = 500;
|
|
6
6
|
/** The keys a click holds, as Playwright's `modifiers` takes them; the contract spells them the same. */
|
|
@@ -151,7 +151,7 @@ export async function dispatchLocatorAction(target, action, timeout, lookup) {
|
|
|
151
151
|
* behind it: no actionability wait, because there is no element to wait on,
|
|
152
152
|
* and the page decides what the gesture lands on, as it does for a person.
|
|
153
153
|
*/
|
|
154
|
-
export async function dispatchPointerAction(page, at, action) {
|
|
154
|
+
export async function dispatchPointerAction(page, at, action, signal) {
|
|
155
155
|
const { mouse } = page;
|
|
156
156
|
const point = nearestPixel(at);
|
|
157
157
|
switch (action.kind) {
|
|
@@ -171,9 +171,11 @@ export async function dispatchPointerAction(page, at, action) {
|
|
|
171
171
|
await mouse.move(point.x, point.y);
|
|
172
172
|
return;
|
|
173
173
|
case 'dragTo':
|
|
174
|
+
await performPointDrag(mouse, point, action.target, undefined, signal);
|
|
175
|
+
return;
|
|
174
176
|
case 'swipeTo':
|
|
175
177
|
// A swipe along a path is a pointer drag on a document platform.
|
|
176
|
-
await performPointDrag(mouse, point, action.target);
|
|
178
|
+
await performPointDrag(mouse, point, action.target, action.durationMs, signal);
|
|
177
179
|
return;
|
|
178
180
|
case 'swipe':
|
|
179
181
|
// The pointer moves to the point first so the scrollable under it receives the wheel.
|
|
@@ -182,6 +184,23 @@ export async function dispatchPointerAction(page, at, action) {
|
|
|
182
184
|
return;
|
|
183
185
|
}
|
|
184
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* An input operation the operation deadline cut off: Playwright never
|
|
189
|
+
* answered, so whether the input reached the page is unknown, and it must not
|
|
190
|
+
* be repeated blindly.
|
|
191
|
+
*/
|
|
192
|
+
function inputCutOff(cause, label) {
|
|
193
|
+
if (!(cause instanceof EngineError) || cause.code !== 'OPERATION_TIMEOUT')
|
|
194
|
+
return undefined;
|
|
195
|
+
return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${label} timed out before the page answered; its input may have been dispatched`, {
|
|
196
|
+
retryable: false,
|
|
197
|
+
cause,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
|
|
201
|
+
export function classifyInputError(cause, label) {
|
|
202
|
+
return inputCutOff(cause, label) ?? translatePwError(cause, label);
|
|
203
|
+
}
|
|
185
204
|
/**
|
|
186
205
|
* Whether a failure of this action may carry the value it was given. A
|
|
187
206
|
* sensitive fill's plaintext must never leave the engine: its message is
|
|
@@ -216,15 +235,25 @@ function actionabilitySummary(text) {
|
|
|
216
235
|
/**
|
|
217
236
|
* Classifies a failed action onto the error contract (see the table above
|
|
218
237
|
* `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
|
|
219
|
-
* committed, or an engine fault.
|
|
238
|
+
* committed, or an engine fault. A strict mode violation is a located match
|
|
239
|
+
* that turned ambiguous: stale like a detached one before any input, so the
|
|
240
|
+
* runner locates again and reports the ambiguity or acts on the one match
|
|
241
|
+
* left; possibly committed once input went out, as the second side of a drag
|
|
242
|
+
* resolves after the press.
|
|
220
243
|
*/
|
|
221
244
|
export function classifyActionError(rawCause, action) {
|
|
245
|
+
const cut = inputCutOff(rawCause, action.kind);
|
|
246
|
+
if (cut !== undefined)
|
|
247
|
+
return cut;
|
|
222
248
|
if (isClassified(rawCause))
|
|
223
249
|
return rawCause;
|
|
224
250
|
const text = redactSensitive(message(rawCause), action);
|
|
225
251
|
const cause = isSensitive(action) ? undefined : rawCause;
|
|
226
252
|
if (/strict mode violation/i.test(text)) {
|
|
227
|
-
|
|
253
|
+
if (POST_DISPATCH_PATTERN.test(text)) {
|
|
254
|
+
return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${action.kind} matched more than one element after its input was dispatched: ${text}`, { retryable: false, cause });
|
|
255
|
+
}
|
|
256
|
+
return new EngineError('NODE_STALE', text, { retryable: true, cause });
|
|
228
257
|
}
|
|
229
258
|
if (DETACHED_PATTERN.test(text)) {
|
|
230
259
|
return new EngineError('NODE_STALE', text, { retryable: true, cause });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright-core';
|
|
2
2
|
import { type EngineCleanupContext, type OperationContext, type VideoSegment, type ViewportSize } from 'e2e/engine';
|
|
3
3
|
import { type CdpEndpointResolver, type SessionBinding } from './cdp-recovery.ts';
|
|
4
|
+
import { type OperationBound } from './operation-budget.ts';
|
|
4
5
|
import { RefRegistry } from './refs.ts';
|
|
5
6
|
import type { LeaseRecording } from './provider.ts';
|
|
6
7
|
import type { WebScreencastOptions } from './surface.ts';
|
|
@@ -57,8 +58,8 @@ export declare class AttemptSession {
|
|
|
57
58
|
private invalidate;
|
|
58
59
|
/** Opens a context for this attempt, publishing only after its handlers are installed. */
|
|
59
60
|
start(signal: AbortSignal): Promise<void>;
|
|
60
|
-
/** Shares one reconnect, then dispatches once with the time that remains. */
|
|
61
|
-
run<T>(operation: OperationContext, label: string, work: (operation: OperationContext) => Promise<T
|
|
61
|
+
/** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
|
|
62
|
+
run<T>(operation: OperationContext, label: string, work: (operation: OperationContext) => Promise<T>, bound?: OperationBound): Promise<T>;
|
|
62
63
|
/** Owns a candidate through configuration and recording setup, then publishes it once. */
|
|
63
64
|
private transition;
|
|
64
65
|
/** Creates the attempt's active page and records its exact target before exposing it. */
|
package/dist/attempt-session.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { EngineError, raceAbort, withinCleanupBudget } from 'e2e/engine';
|
|
5
5
|
import { attachPersistent, recoveryFailed, targetIdentity } from './cdp-recovery.js';
|
|
6
|
-
import { connectionAbort, withConnectionBudget } from './operation-budget.js';
|
|
6
|
+
import { connectionAbort, withConnectionBudget, withOperationDeadline } from './operation-budget.js';
|
|
7
7
|
import { RefRegistry } from './refs.js';
|
|
8
8
|
import { ProviderVideo } from './provider-video.js';
|
|
9
9
|
import { invalidState, translatePwError } from './support.js';
|
|
@@ -83,8 +83,8 @@ export class AttemptSession {
|
|
|
83
83
|
return binding;
|
|
84
84
|
}));
|
|
85
85
|
}
|
|
86
|
-
/** Shares one reconnect, then dispatches once with the time that remains. */
|
|
87
|
-
async run(operation, label, work) {
|
|
86
|
+
/** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
|
|
87
|
+
async run(operation, label, work, bound = 'deadline') {
|
|
88
88
|
const signal = AbortSignal.any([operation.signal, this.lifetime.signal]);
|
|
89
89
|
if (signal.aborted)
|
|
90
90
|
throw connectionAbort(signal, label);
|
|
@@ -92,23 +92,26 @@ export class AttemptSession {
|
|
|
92
92
|
throw this.state.error;
|
|
93
93
|
const persistent = this.options.persistent;
|
|
94
94
|
const reconnect = (this.state.kind === 'pending' || (this.state.kind === 'ready' && !this.state.binding.browser.isConnected()));
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
const endsAt = Date.now() + operation.timeoutMs;
|
|
96
|
+
// Recovery is the engine's own work, so the deadline bounds it whatever the operation runs.
|
|
97
|
+
if (persistent !== undefined && reconnect) {
|
|
98
|
+
await withConnectionBudget({ signal, timeoutMs: operation.timeoutMs }, label, async (remaining) => {
|
|
99
|
+
if (this.state.kind === 'pending') {
|
|
100
|
+
await raceAbort(this.state.work, remaining().signal, 'CDP recovery');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
102
103
|
const previous = this.current();
|
|
103
104
|
this.observed = false;
|
|
104
105
|
await this.transition(previous, remaining(), async () => {
|
|
105
106
|
await this.video.pageClosing();
|
|
106
107
|
return attachPersistent(persistent.reconnect, remaining(), previous.identity);
|
|
107
108
|
}, true);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const left = endsAt - Date.now();
|
|
112
|
+
if (left <= 0)
|
|
113
|
+
throw new EngineError('OPERATION_TIMEOUT', `${label} timed out`, { retryable: false });
|
|
114
|
+
return withOperationDeadline({ signal, timeoutMs: left }, label, (remaining) => work({ ...operation, ...remaining() }), bound);
|
|
112
115
|
}
|
|
113
116
|
/** Owns a candidate through configuration and recording setup, then publishes it once. */
|
|
114
117
|
async transition(previous, budget, open, recover = false) {
|
package/dist/browser.d.ts
CHANGED
|
@@ -169,7 +169,7 @@ export interface Browser extends Expectable<BrowserExpectation> {
|
|
|
169
169
|
locator(selector: string): Locator;
|
|
170
170
|
/** Creates a screen query scope inside one iframe. */
|
|
171
171
|
frameLocator(selector: string): FrameScreen;
|
|
172
|
-
/** Evaluates trusted test code in the page. */
|
|
172
|
+
/** Evaluates trusted test code in the page: a function, or a string expression whose value is returned, called first if it is a function. */
|
|
173
173
|
evaluate<T extends JsonValue>(fn: string | (() => T | Promise<T>)): Promise<T>;
|
|
174
174
|
/** Evaluates trusted test code with one required JSON-safe argument. */
|
|
175
175
|
evaluate<T extends JsonValue, Arg extends JsonValue>(fn: string | ((arg: Arg) => T | Promise<T>), arg: Arg): Promise<T>;
|
package/dist/browser.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
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
18
|
import { compileEvaluation } from './evaluation.js';
|
|
@@ -62,8 +63,10 @@ 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) {
|
|
@@ -85,8 +88,8 @@ export function createBrowserFixture(surface, context) {
|
|
|
85
88
|
forward: (options) => navigation(options, async (operation) => {
|
|
86
89
|
await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
87
90
|
}),
|
|
88
|
-
url: currentUrl,
|
|
89
|
-
title: currentTitle,
|
|
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']);
|
|
@@ -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/evaluation.d.ts
CHANGED
|
@@ -9,11 +9,14 @@ type EvaluationResult = {
|
|
|
9
9
|
export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
10
10
|
/**
|
|
11
11
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
12
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
13
|
+
* other value is the result.
|
|
12
14
|
*
|
|
13
15
|
* 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
|
|
16
|
+
* it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
|
|
17
|
+
* wraps every nested named binding in a module-scoped `__name(target, name)`
|
|
18
|
+
* helper; the page has no such helper, so the same one is declared next to
|
|
19
|
+
* the inlined source for as long as this engine supports those runners.
|
|
17
20
|
*/
|
|
18
21
|
export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
|
|
19
22
|
export {};
|
package/dist/evaluation.js
CHANGED
|
@@ -5,11 +5,14 @@ import { message } from './support.js';
|
|
|
5
5
|
export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
6
6
|
/**
|
|
7
7
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
8
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
9
|
+
* other value is the result.
|
|
8
10
|
*
|
|
9
11
|
* 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
|
|
12
|
+
* it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
|
|
13
|
+
* wraps every nested named binding in a module-scoped `__name(target, name)`
|
|
14
|
+
* helper; the page has no such helper, so the same one is declared next to
|
|
15
|
+
* the inlined source for as long as this engine supports those runners.
|
|
13
16
|
*/
|
|
14
17
|
export function compileEvaluation(source, hasArgument) {
|
|
15
18
|
try {
|
|
@@ -17,7 +20,7 @@ export function compileEvaluation(source, hasArgument) {
|
|
|
17
20
|
return (async () => {
|
|
18
21
|
${KEEP_NAMES_HELPER}
|
|
19
22
|
try {
|
|
20
|
-
return { ok: true, value: await (
|
|
23
|
+
return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
|
|
21
24
|
} catch (cause) {
|
|
22
25
|
let message;
|
|
23
26
|
try {
|
|
@@ -219,7 +219,21 @@ export const readSemanticsFunction = (element, options) => {
|
|
|
219
219
|
}
|
|
220
220
|
case 'th': {
|
|
221
221
|
const scope = (el.getAttribute('scope') ?? '').toLowerCase();
|
|
222
|
-
|
|
222
|
+
if (scope === 'row' || scope === 'rowgroup')
|
|
223
|
+
return 'rowheader';
|
|
224
|
+
if (scope === 'col' || scope === 'colgroup')
|
|
225
|
+
return 'columnheader';
|
|
226
|
+
const previous = el.previousElementSibling;
|
|
227
|
+
const next = el.nextElementSibling;
|
|
228
|
+
if (previous === null && next === null) {
|
|
229
|
+
const row = el.parentElement;
|
|
230
|
+
const table = row?.tagName.toLowerCase() === 'tr' ? row.closest('table') : null;
|
|
231
|
+
return table !== null && table.rows.length <= 1 ? null : 'columnheader';
|
|
232
|
+
}
|
|
233
|
+
if (previous?.tagName.toLowerCase() === 'th' && next?.tagName.toLowerCase() === 'th')
|
|
234
|
+
return 'columnheader';
|
|
235
|
+
const hasDataNeighbor = [previous, next].some((cell) => cell?.tagName.toLowerCase() === 'td' && ((cell.textContent ?? '').trim() !== '' || cell.children.length > 0));
|
|
236
|
+
return hasDataNeighbor ? 'rowheader' : 'columnheader';
|
|
223
237
|
}
|
|
224
238
|
case 'dialog':
|
|
225
239
|
return 'dialog';
|
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`,
|
package/dist/install.js
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
/** First-run browser provisioning for the Playwright engine. */
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { EngineError, InfrastructureError } from 'e2e/engine';
|
|
8
8
|
import { browserType } from './browser-connection.js';
|
|
9
|
+
/** Whether `chromium_headless_shell-<revision>` under `browsersPath` finished installing and holds its executable. */
|
|
10
|
+
function headlessShellInstalled(browsersPath, revision) {
|
|
11
|
+
const shellRoot = path.join(browsersPath, `chromium_headless_shell-${revision}`);
|
|
12
|
+
if (!existsSync(path.join(shellRoot, 'INSTALLATION_COMPLETE')))
|
|
13
|
+
return false;
|
|
14
|
+
const executable = process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
|
|
15
|
+
return readdirSync(shellRoot, { withFileTypes: true }).some((entry) => entry.isDirectory() && existsSync(path.join(shellRoot, entry.name, executable)));
|
|
16
|
+
}
|
|
9
17
|
/**
|
|
10
|
-
* Whether the
|
|
11
|
-
*
|
|
12
|
-
* `PLAYWRIGHT_BROWSERS_PATH` at module load, so the in-process check is only
|
|
13
|
-
* authoritative when the run's environment agrees with this process's. A run
|
|
14
|
-
* pointed at another cache is left to the CLI, which resolves against the
|
|
15
|
-
* environment it is spawned with and is a no-op when nothing is missing.
|
|
18
|
+
* Whether the build the run launches (headless shell for headless chromium, else the full build)
|
|
19
|
+
* is on disk, or undefined when `env` points at another cache than this process loaded.
|
|
16
20
|
*/
|
|
17
|
-
function isBrowserInstalled(name, env) {
|
|
21
|
+
function isBrowserInstalled(name, env, headed) {
|
|
18
22
|
if (env['PLAYWRIGHT_BROWSERS_PATH'] !== process.env['PLAYWRIGHT_BROWSERS_PATH'])
|
|
19
23
|
return undefined;
|
|
20
24
|
try {
|
|
21
|
-
|
|
25
|
+
const executable = browserType(name).executablePath();
|
|
26
|
+
if (headed || name !== 'chromium')
|
|
27
|
+
return existsSync(executable);
|
|
28
|
+
return headlessShellAt(executable);
|
|
22
29
|
}
|
|
23
30
|
catch {
|
|
24
31
|
return false;
|
|
25
32
|
}
|
|
26
33
|
}
|
|
34
|
+
/** Whether the shell beside `executable`'s full build is installed. */
|
|
35
|
+
function headlessShellAt(executable) {
|
|
36
|
+
const marker = `${path.sep}chromium-`;
|
|
37
|
+
const at = executable.lastIndexOf(marker);
|
|
38
|
+
if (at === -1)
|
|
39
|
+
return false;
|
|
40
|
+
const revision = executable.slice(at + marker.length).split(path.sep)[0] ?? '';
|
|
41
|
+
return revision !== '' && headlessShellInstalled(executable.slice(0, at), revision);
|
|
42
|
+
}
|
|
27
43
|
/** The `node` arguments that run the pinned `playwright-core` CLI with `args`. */
|
|
28
44
|
function playwrightCliArgs(args) {
|
|
29
45
|
const require = createRequire(import.meta.url);
|
|
@@ -37,7 +53,10 @@ function playwrightCliArgs(args) {
|
|
|
37
53
|
*/
|
|
38
54
|
export function runPlaywrightCli(args) {
|
|
39
55
|
return new Promise((resolve, reject) => {
|
|
40
|
-
const child = spawn(process.execPath, playwrightCliArgs(args), {
|
|
56
|
+
const child = spawn(process.execPath, playwrightCliArgs(args), {
|
|
57
|
+
stdio: 'inherit',
|
|
58
|
+
env: installEnvironment(),
|
|
59
|
+
});
|
|
41
60
|
const forward = (signal) => child.kill(signal);
|
|
42
61
|
process.on('SIGINT', forward);
|
|
43
62
|
process.on('SIGTERM', forward);
|
|
@@ -55,6 +74,16 @@ export function runPlaywrightCli(args) {
|
|
|
55
74
|
});
|
|
56
75
|
});
|
|
57
76
|
}
|
|
77
|
+
/** The `playwright install` arguments for `names`: `--only-shell` on a headless chromium run. */
|
|
78
|
+
export function installArgs(names, headed) {
|
|
79
|
+
if (headed || !names.includes('chromium'))
|
|
80
|
+
return [...names];
|
|
81
|
+
return ['--only-shell', ...names];
|
|
82
|
+
}
|
|
83
|
+
/** `env` with `PLAYWRIGHT_SKIP_BROWSER_GC=1` unless already set, so an install keeps other tools' browsers. */
|
|
84
|
+
function installEnvironment(env = process.env) {
|
|
85
|
+
return { ...env, PLAYWRIGHT_SKIP_BROWSER_GC: env['PLAYWRIGHT_SKIP_BROWSER_GC'] ?? '1' };
|
|
86
|
+
}
|
|
58
87
|
/**
|
|
59
88
|
* Ensures the given Playwright browsers are installed, downloading any
|
|
60
89
|
* missing ones via `playwright-core install`. Called from the engine's `prepare`,
|
|
@@ -65,7 +94,8 @@ export function runPlaywrightCli(args) {
|
|
|
65
94
|
*/
|
|
66
95
|
export async function ensureBrowsersInstalled(names, options = {}) {
|
|
67
96
|
const env = options.env ?? process.env;
|
|
68
|
-
const
|
|
97
|
+
const headed = options.headed ?? false;
|
|
98
|
+
const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env, headed));
|
|
69
99
|
const verdicts = new Map([...new Set(names)].map((name) => [name, isInstalled(name)]));
|
|
70
100
|
const missing = [...verdicts.keys()].filter((name) => verdicts.get(name) !== true);
|
|
71
101
|
if (missing.length === 0)
|
|
@@ -78,7 +108,7 @@ export async function ensureBrowsersInstalled(names, options = {}) {
|
|
|
78
108
|
if (knownMissing)
|
|
79
109
|
log(`Downloading missing Playwright browsers (first run): ${missing.join(', ')}...`);
|
|
80
110
|
const install = options.install ?? runPlaywrightInstall;
|
|
81
|
-
await install(missing, { log, signal: options.signal, env });
|
|
111
|
+
await install(missing, { log, signal: options.signal, env: installEnvironment(env), headed });
|
|
82
112
|
if (knownMissing)
|
|
83
113
|
log('Browser download complete.');
|
|
84
114
|
}
|
|
@@ -109,13 +139,13 @@ function forwardLines(stream, log) {
|
|
|
109
139
|
stream.on('end', () => flush(pending));
|
|
110
140
|
}
|
|
111
141
|
/** Spawns `node <playwright-core>/cli.js install <names>`, narrating its output through `log`. */
|
|
112
|
-
function runPlaywrightInstall(names, { log, signal, env }) {
|
|
142
|
+
function runPlaywrightInstall(names, { log, signal, env, headed }) {
|
|
113
143
|
return new Promise((resolve, reject) => {
|
|
114
144
|
if (signal?.aborted === true) {
|
|
115
145
|
reject(new EngineError('CANCELLED', 'browser install cancelled', { retryable: false }));
|
|
116
146
|
return;
|
|
117
147
|
}
|
|
118
|
-
const child = spawn(process.execPath, playwrightCliArgs(['install', ...names]), {
|
|
148
|
+
const child = spawn(process.execPath, playwrightCliArgs(['install', ...installArgs(names, headed)]), {
|
|
119
149
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
150
|
env,
|
|
121
151
|
});
|
|
@@ -6,5 +6,19 @@ export interface ConnectionBudget {
|
|
|
6
6
|
}
|
|
7
7
|
/** Preserves the reason for an engine-owned deadline without relabeling caller cancellation. */
|
|
8
8
|
export declare function connectionAbort(signal: AbortSignal, label: string): EngineError;
|
|
9
|
+
/**
|
|
10
|
+
* Whether the operation's deadline may end it. `test-code` is for an
|
|
11
|
+
* operation that runs the test's own code (a download trigger): that code
|
|
12
|
+
* keeps its own steps and their budgets, so the operation is not cut under
|
|
13
|
+
* it, and only cancellation ends the wait.
|
|
14
|
+
*/
|
|
15
|
+
export type OperationBound = 'deadline' | 'test-code';
|
|
16
|
+
/**
|
|
17
|
+
* Bounds one operation by its budget: a call still pending at the deadline
|
|
18
|
+
* is abandoned as `OPERATION_TIMEOUT` instead of holding the test until its
|
|
19
|
+
* own timeout. `remaining` hands `work` the time left before Playwright's
|
|
20
|
+
* own timeout, the one to pass Playwright.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withOperationDeadline<T>(budget: ConnectionBudget, label: string, work: (remaining: () => ConnectionBudget) => Promise<T>, bound?: OperationBound): Promise<T>;
|
|
9
23
|
/** Bounds the entire transition, aborts abandoned work, and never grants a fresh dispatch budget. */
|
|
10
24
|
export declare function withConnectionBudget<T>(budget: ConnectionBudget, label: string, work: (remaining: () => ConnectionBudget) => Promise<T>): Promise<T>;
|
package/dist/operation-budget.js
CHANGED
|
@@ -6,6 +6,37 @@ export function connectionAbort(signal, label) {
|
|
|
6
6
|
? signal.reason
|
|
7
7
|
: new EngineError('CANCELLED', `${label} cancelled`, { retryable: false });
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* How much sooner than an operation's deadline Playwright's own timeout
|
|
11
|
+
* fires: its error names what blocked an action, or that the input was
|
|
12
|
+
* already dispatched, and this lead lets that answer arrive before the
|
|
13
|
+
* deadline does. The deadline is for the calls Playwright never answers: an
|
|
14
|
+
* evaluate, which takes no timeout, and every call while a trace snapshots a
|
|
15
|
+
* page whose renderer is stuck in a script. A budget shorter than twice the
|
|
16
|
+
* lead splits in half.
|
|
17
|
+
*/
|
|
18
|
+
const PLAYWRIGHT_TIMEOUT_LEAD_MS = 250;
|
|
19
|
+
/**
|
|
20
|
+
* Bounds one operation by its budget: a call still pending at the deadline
|
|
21
|
+
* is abandoned as `OPERATION_TIMEOUT` instead of holding the test until its
|
|
22
|
+
* own timeout. `remaining` hands `work` the time left before Playwright's
|
|
23
|
+
* own timeout, the one to pass Playwright.
|
|
24
|
+
*/
|
|
25
|
+
export function withOperationDeadline(budget, label, work, bound = 'deadline') {
|
|
26
|
+
if (bound === 'test-code') {
|
|
27
|
+
const endsAt = Date.now() + budget.timeoutMs;
|
|
28
|
+
return raceAbort(() => work(() => {
|
|
29
|
+
if (budget.signal.aborted)
|
|
30
|
+
throw connectionAbort(budget.signal, label);
|
|
31
|
+
return { signal: budget.signal, timeoutMs: Math.max(1, endsAt - Date.now()) };
|
|
32
|
+
}), budget.signal, label);
|
|
33
|
+
}
|
|
34
|
+
return withConnectionBudget(budget, label, (remaining) => work(() => {
|
|
35
|
+
const current = remaining();
|
|
36
|
+
const lead = Math.min(PLAYWRIGHT_TIMEOUT_LEAD_MS, current.timeoutMs / 2);
|
|
37
|
+
return { signal: current.signal, timeoutMs: Math.ceil(current.timeoutMs - lead) };
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
9
40
|
/** Bounds the entire transition, aborts abandoned work, and never grants a fresh dispatch budget. */
|
|
10
41
|
export async function withConnectionBudget(budget, label, work) {
|
|
11
42
|
const controller = new AbortController();
|
package/dist/support.d.ts
CHANGED
|
@@ -102,19 +102,23 @@ export declare function isTestErrorCode(cause: unknown, code: string): cause is
|
|
|
102
102
|
* | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
|
|
103
103
|
* | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
|
|
104
104
|
* | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
|
|
105
|
+
* | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
|
|
105
106
|
* | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
|
|
106
107
|
* | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
|
|
108
|
+
* | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
|
|
109
|
+
* | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
|
|
107
110
|
* | not an input / not editable / not checkable | NOT_ACTIONABLE |
|
|
108
|
-
* |
|
|
111
|
+
* | anything else | ENGINE_FAILURE |
|
|
109
112
|
*
|
|
110
113
|
* The action split is read from the call log Playwright appends to a timeout
|
|
111
|
-
* message: `performing <x> action`, `<x> action done`, and
|
|
112
|
-
* scheduled navigations to finish` are only logged once the input
|
|
113
|
-
* has been) dispatched, so a
|
|
114
|
-
* the harness must not blindly repeat it. Every earlier line
|
|
115
|
-
* element to be visible, enabled and stable`, `scrolling into
|
|
116
|
-
* needed`, `retrying <x> action`) precedes dispatch and is a plain
|
|
117
|
-
* actionability miss.
|
|
114
|
+
* or strict mode message: `performing <x> action`, `<x> action done`, and
|
|
115
|
+
* `waiting for scheduled navigations to finish` are only logged once the input
|
|
116
|
+
* is being (or has been) dispatched, so a failure whose log reaches them is
|
|
117
|
+
* uncertain and the harness must not blindly repeat it. Every earlier line
|
|
118
|
+
* (`waiting for element to be visible, enabled and stable`, `scrolling into
|
|
119
|
+
* view if needed`, `retrying <x> action`) precedes dispatch and is a plain
|
|
120
|
+
* actionability miss. Only whole `- ` log lines count, so a locator or element
|
|
121
|
+
* text quoting those words never does.
|
|
118
122
|
*/
|
|
119
123
|
export declare const POST_DISPATCH_PATTERN: RegExp;
|
|
120
124
|
/** Translates an unexpected Playwright error at the contract boundary. */
|
|
@@ -142,7 +146,7 @@ export declare function performViewportSwipe(page: Page, direction: ScrollDirect
|
|
|
142
146
|
* CSS pixel instead.
|
|
143
147
|
*/
|
|
144
148
|
export declare function nearestPixel(point: ViewportPoint): ViewportPoint;
|
|
145
|
-
/** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
|
|
146
|
-
export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint): Promise<void>;
|
|
149
|
+
/** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
|
|
150
|
+
export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint, durationMs?: number, signal?: AbortSignal): Promise<void>;
|
|
147
151
|
export declare function performElementSwipe(target: ActionTarget, direction: ScrollDirection, momentum: Momentum, timeout: number): Promise<void>;
|
|
148
152
|
export {};
|
package/dist/support.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Shared error translation, filename, and swipe helpers for the Playwright engine. */
|
|
2
|
+
import { setTimeout } from 'node:timers/promises';
|
|
2
3
|
import { EngineError, withinCleanupBudget } from 'e2e/engine';
|
|
3
4
|
import { ConfigurationError, InfrastructureError, TestError } from 'e2e/engine';
|
|
4
5
|
export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
@@ -193,11 +194,12 @@ export class ErrorLatch {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
/**
|
|
196
|
-
* The runner error classes as their `name` reads. A project
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* `name` and `code` still
|
|
197
|
+
* The runner error classes as their `name` reads. A project can load this
|
|
198
|
+
* engine against a copy of `e2e` other than the one the runner's core runs
|
|
199
|
+
* from (and e2e before 0.17 always loaded config in a module realm of its
|
|
200
|
+
* own), so a runner error reaching the engine can be a `TestError` this
|
|
201
|
+
* module never imported: `instanceof` says no while `name` and `code` still
|
|
202
|
+
* tell the truth.
|
|
201
203
|
*/
|
|
202
204
|
const CLASSIFIED_NAMES = new Set([
|
|
203
205
|
'EngineError',
|
|
@@ -236,21 +238,25 @@ export function isTestErrorCode(cause, code) {
|
|
|
236
238
|
* | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
|
|
237
239
|
* | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
|
|
238
240
|
* | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
|
|
241
|
+
* | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
|
|
239
242
|
* | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
|
|
240
243
|
* | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
|
|
244
|
+
* | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
|
|
245
|
+
* | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
|
|
241
246
|
* | not an input / not editable / not checkable | NOT_ACTIONABLE |
|
|
242
|
-
* |
|
|
247
|
+
* | anything else | ENGINE_FAILURE |
|
|
243
248
|
*
|
|
244
249
|
* The action split is read from the call log Playwright appends to a timeout
|
|
245
|
-
* message: `performing <x> action`, `<x> action done`, and
|
|
246
|
-
* scheduled navigations to finish` are only logged once the input
|
|
247
|
-
* has been) dispatched, so a
|
|
248
|
-
* the harness must not blindly repeat it. Every earlier line
|
|
249
|
-
* element to be visible, enabled and stable`, `scrolling into
|
|
250
|
-
* needed`, `retrying <x> action`) precedes dispatch and is a plain
|
|
251
|
-
* actionability miss.
|
|
250
|
+
* or strict mode message: `performing <x> action`, `<x> action done`, and
|
|
251
|
+
* `waiting for scheduled navigations to finish` are only logged once the input
|
|
252
|
+
* is being (or has been) dispatched, so a failure whose log reaches them is
|
|
253
|
+
* uncertain and the harness must not blindly repeat it. Every earlier line
|
|
254
|
+
* (`waiting for element to be visible, enabled and stable`, `scrolling into
|
|
255
|
+
* view if needed`, `retrying <x> action`) precedes dispatch and is a plain
|
|
256
|
+
* actionability miss. Only whole `- ` log lines count, so a locator or element
|
|
257
|
+
* text quoting those words never does.
|
|
252
258
|
*/
|
|
253
|
-
export const POST_DISPATCH_PATTERN =
|
|
259
|
+
export const POST_DISPATCH_PATTERN = /^\s*- (performing \w+ action|[\w ]+ action done|waiting for scheduled navigations to finish)\s*$/im;
|
|
254
260
|
/** Translates an unexpected Playwright error at the contract boundary. */
|
|
255
261
|
export function translatePwError(cause, operation) {
|
|
256
262
|
if (isClassified(cause))
|
|
@@ -321,16 +327,31 @@ export async function performViewportSwipe(page, direction, momentum) {
|
|
|
321
327
|
export function nearestPixel(point) {
|
|
322
328
|
return { x: Math.round(point.x), y: Math.round(point.y) };
|
|
323
329
|
}
|
|
324
|
-
/** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
|
|
325
|
-
export async function performPointDrag(mouse, start, end) {
|
|
330
|
+
/** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
|
|
331
|
+
export async function performPointDrag(mouse, start, end, durationMs, signal) {
|
|
326
332
|
const from = nearestPixel(start);
|
|
327
333
|
const to = nearestPixel(end);
|
|
328
334
|
const middle = nearestPixel({ x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 });
|
|
329
335
|
await mouse.move(from.x, from.y);
|
|
330
336
|
await mouse.down();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
337
|
+
try {
|
|
338
|
+
if (durationMs !== undefined) {
|
|
339
|
+
const steps = Math.max(2, Math.round(durationMs / 16));
|
|
340
|
+
const startedAt = performance.now();
|
|
341
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
342
|
+
const due = startedAt + (durationMs * step) / steps;
|
|
343
|
+
await setTimeout(Math.max(0, due - performance.now()), undefined, { signal });
|
|
344
|
+
await mouse.move(from.x + ((to.x - from.x) * step) / steps, from.y + ((to.y - from.y) * step) / steps);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
await mouse.move(middle.x, middle.y);
|
|
349
|
+
await mouse.move(to.x, to.y);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
await mouse.up();
|
|
354
|
+
}
|
|
334
355
|
}
|
|
335
356
|
export async function performElementSwipe(target, direction, momentum, timeout) {
|
|
336
357
|
// Hover first: it auto-waits for visibility on both target kinds, so the
|
package/dist/surface.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type EngineAttemptContext, type EngineCleanupContext, type EngineInitIn
|
|
|
11
11
|
import { type BrowserName } from './browser-connection.ts';
|
|
12
12
|
import { type BrowserProvider, type LeaseDownloads } from './provider.ts';
|
|
13
13
|
import { DialogRouter } from './dialogs.ts';
|
|
14
|
+
import { type OperationBound } from './operation-budget.ts';
|
|
14
15
|
import { type WebInitScript } from './init-scripts.ts';
|
|
15
16
|
import { ErrorLatch } from './support.ts';
|
|
16
17
|
type RoutePredicate = (url: URL) => boolean;
|
|
@@ -297,11 +298,13 @@ export declare class PlaywrightSurface {
|
|
|
297
298
|
* The single entry of every operation: rethrows an error latched on an
|
|
298
299
|
* unawaited path, refuses a cancelled operation, races `fn` against the
|
|
299
300
|
* operation signal so an abort mid-call surfaces as `CANCELLED` instead of
|
|
300
|
-
* waiting out Playwright,
|
|
301
|
+
* waiting out Playwright, bounds it by the operation's budget so a call a
|
|
302
|
+
* hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
|
|
301
303
|
* boundary. `translate` overrides the default translation for operations
|
|
302
|
-
* with a documented retryable failure mode
|
|
304
|
+
* with a documented retryable failure mode; `bound` is `test-code` for an
|
|
305
|
+
* operation that runs the test's own code, which the deadline never cuts.
|
|
303
306
|
*/
|
|
304
|
-
guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error): Promise<T>;
|
|
307
|
+
guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error, bound?: OperationBound): Promise<T>;
|
|
305
308
|
/** Opens one URL the harness resolved; the attempt's page is created on first use. */
|
|
306
309
|
open(url: string, operation: OperationContext): Promise<void>;
|
|
307
310
|
back(operation: OperationContext): Promise<void>;
|
package/dist/surface.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { EngineError, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
|
|
12
12
|
import { matchesText } from 'e2e/engine';
|
|
13
|
-
import { classifyActionError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
|
|
13
|
+
import { classifyActionError, classifyInputError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
|
|
14
14
|
import { BrowserConnection, connectCdp } from './browser-connection.js';
|
|
15
15
|
import { AttemptSession } from './attempt-session.js';
|
|
16
16
|
import { LeasedBrowsers } from './provider.js';
|
|
@@ -20,7 +20,7 @@ import { applyPostSteps, frameSelectors, projectExpression } from './locators.js
|
|
|
20
20
|
import { ROOT_NODE_ID, toSemanticNode } from './observation.js';
|
|
21
21
|
import { captureObservation } from './observation-capture.js';
|
|
22
22
|
import { maskOptions, secureFieldMasks } from './observe.js';
|
|
23
|
-
import { connectionAbort } from './operation-budget.js';
|
|
23
|
+
import { connectionAbort, withOperationDeadline } from './operation-budget.js';
|
|
24
24
|
import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT } from './closed-shadow.js';
|
|
25
25
|
import { ConfiguredInitScripts } from './init-scripts.js';
|
|
26
26
|
import { SECURE_FIELD_SELECTOR } from './read-node.js';
|
|
@@ -109,7 +109,7 @@ export class PlaywrightSurface {
|
|
|
109
109
|
return this.leases.prepare(info);
|
|
110
110
|
if (this.connect !== undefined)
|
|
111
111
|
return;
|
|
112
|
-
await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log });
|
|
112
|
+
await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log, headed: info.headed });
|
|
113
113
|
}
|
|
114
114
|
/** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
|
|
115
115
|
async finish(info) {
|
|
@@ -395,16 +395,18 @@ export class PlaywrightSurface {
|
|
|
395
395
|
* The single entry of every operation: rethrows an error latched on an
|
|
396
396
|
* unawaited path, refuses a cancelled operation, races `fn` against the
|
|
397
397
|
* operation signal so an abort mid-call surfaces as `CANCELLED` instead of
|
|
398
|
-
* waiting out Playwright,
|
|
398
|
+
* waiting out Playwright, bounds it by the operation's budget so a call a
|
|
399
|
+
* hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
|
|
399
400
|
* boundary. `translate` overrides the default translation for operations
|
|
400
|
-
* with a documented retryable failure mode
|
|
401
|
+
* with a documented retryable failure mode; `bound` is `test-code` for an
|
|
402
|
+
* operation that runs the test's own code, which the deadline never cuts.
|
|
401
403
|
*/
|
|
402
|
-
async guard(operation, label, fn, translate = translatePwError) {
|
|
404
|
+
async guard(operation, label, fn, translate = translatePwError, bound = 'deadline') {
|
|
403
405
|
this.latch.throwPending();
|
|
404
406
|
try {
|
|
405
407
|
return this.session === undefined
|
|
406
|
-
? await
|
|
407
|
-
: await this.session.run(operation, label, fn);
|
|
408
|
+
? await withOperationDeadline(operation, label, (remaining) => fn({ ...operation, ...remaining() }), bound)
|
|
409
|
+
: await this.session.run(operation, label, fn, bound);
|
|
408
410
|
}
|
|
409
411
|
catch (cause) {
|
|
410
412
|
throw translate(cause, label);
|
|
@@ -519,7 +521,7 @@ export class PlaywrightSurface {
|
|
|
519
521
|
return matches.map(({ raw, index }) => {
|
|
520
522
|
const pinned = handles?.[index];
|
|
521
523
|
// A single match keeps the strict locator, so a ref that turns
|
|
522
|
-
// ambiguous between locate and perform
|
|
524
|
+
// ambiguous between locate and perform is stale instead of acting
|
|
523
525
|
// on whichever element is first.
|
|
524
526
|
const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
|
|
525
527
|
const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
|
|
@@ -545,10 +547,10 @@ export class PlaywrightSurface {
|
|
|
545
547
|
}
|
|
546
548
|
/** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
|
|
547
549
|
performAt(point, action, operation) {
|
|
548
|
-
return this.guard(operation, `${action.kind} at point`, () => {
|
|
550
|
+
return this.guard(operation, `${action.kind} at point`, (currentOperation) => {
|
|
549
551
|
this.requireSession().requireObservation();
|
|
550
|
-
return dispatchPointerAction(this.requirePage(), point, action);
|
|
551
|
-
});
|
|
552
|
+
return dispatchPointerAction(this.requirePage(), point, action, currentOperation.signal);
|
|
553
|
+
}, classifyInputError);
|
|
552
554
|
}
|
|
553
555
|
/**
|
|
554
556
|
* Types into whatever holds focus. Keystrokes into a document body are
|
|
@@ -578,14 +580,14 @@ export class PlaywrightSurface {
|
|
|
578
580
|
checkpoint();
|
|
579
581
|
}
|
|
580
582
|
await page.keyboard.type(text);
|
|
581
|
-
});
|
|
583
|
+
}, classifyInputError);
|
|
582
584
|
}
|
|
583
585
|
/** Sends one key to whatever holds focus, in the contract's key grammar Playwright shares. */
|
|
584
586
|
pressKey(key, operation) {
|
|
585
587
|
return this.guard(operation, 'keyboard.press', () => {
|
|
586
588
|
this.requireSession().requireObservation();
|
|
587
589
|
return this.requirePage().keyboard.press(key);
|
|
588
|
-
});
|
|
590
|
+
}, classifyInputError);
|
|
589
591
|
}
|
|
590
592
|
/**
|
|
591
593
|
* Refuses focused typing when nothing that takes keystrokes has focus, in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@e2e-dev/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"e2e",
|
|
@@ -50,18 +50,18 @@
|
|
|
50
50
|
"playwright-core": "1.63.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"e2e": ">=0.
|
|
53
|
+
"e2e": ">=0.18.0 <1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "26.6.2",
|
|
57
|
-
"e2e": "0.
|
|
57
|
+
"e2e": "0.18.0",
|
|
58
58
|
"@types/pngjs": "6.0.5",
|
|
59
59
|
"pngjs": "7.0.0",
|
|
60
60
|
"typescript": "7.0.2",
|
|
61
61
|
"vitest": "5.0.1"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
|
-
"node": "
|
|
64
|
+
"node": "^22.22.3 || >=24.8.0"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
67
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json && node ../../scripts/stamp-dist.ts",
|