@e2e-dev/web 0.11.2 → 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/README.md +15 -9
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +9 -3
- package/dist/actions.js +87 -9
- package/dist/attempt-session.d.ts +4 -3
- package/dist/attempt-session.js +17 -14
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/browser-connection.d.ts +7 -3
- package/dist/browser-connection.js +7 -1
- package/dist/browser.d.ts +44 -7
- package/dist/browser.js +135 -72
- package/dist/capture-scope.d.ts +1 -1
- package/dist/cdp-recovery.d.ts +1 -1
- package/dist/cdp-selectors.d.ts +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +42 -0
- package/dist/dialogs.d.ts +1 -1
- package/dist/downloads.d.ts +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +74 -16
- package/dist/evaluation.d.ts +8 -3
- package/dist/evaluation.js +10 -5
- package/dist/in-page/read-semantics.d.ts +1 -0
- package/dist/in-page/read-semantics.js +166 -40
- package/dist/index.d.ts +2 -1
- package/dist/init-scripts.d.ts +30 -0
- package/dist/init-scripts.js +105 -0
- package/dist/install.d.ts +14 -1
- package/dist/install.js +77 -20
- package/dist/locators.d.ts +1 -1
- package/dist/locators.js +30 -18
- package/dist/observation-capture.d.ts +1 -1
- package/dist/observation.d.ts +1 -1
- package/dist/observation.js +3 -3
- package/dist/observe.d.ts +1 -1
- package/dist/operation-budget.d.ts +14 -0
- package/dist/operation-budget.js +31 -0
- package/dist/protected-app.d.ts +20 -4
- package/dist/protected-app.js +35 -4
- package/dist/provider-video.d.ts +1 -1
- package/dist/read-node.d.ts +9 -2
- package/dist/route-options.d.ts +44 -0
- package/dist/route-options.js +125 -0
- package/dist/selector-engines.js +2 -0
- package/dist/shown-selector.d.ts +20 -0
- package/dist/shown-selector.js +27 -0
- package/dist/support.d.ts +18 -12
- package/dist/support.js +41 -20
- package/dist/surface.d.ts +46 -6
- package/dist/surface.js +60 -19
- package/dist/video.d.ts +1 -1
- package/package.json +10 -6
package/dist/browser.js
CHANGED
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
* never re-implemented here: `context.app.resolveUrl` is the one place that
|
|
12
12
|
* says which URLs a test may open.
|
|
13
13
|
*/
|
|
14
|
-
import { Deadline, describePattern, EngineError, matchesText, pollCondition, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, } from 'e2e/engine';
|
|
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';
|
|
19
|
+
import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
|
|
20
|
+
import { lowercaseNames } from './protected-app.js';
|
|
21
|
+
import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
|
|
18
22
|
import { routePatternMatches, routePatternsEqual } from './route-pattern.js';
|
|
19
23
|
/**
|
|
20
24
|
* Reads the body once the response is known and keeps the outcome: a body
|
|
@@ -59,8 +63,10 @@ export function createBrowserFixture(surface, context) {
|
|
|
59
63
|
};
|
|
60
64
|
/** An assertion-style budget: the given timeout or the assertion timeout, clamped to the test. */
|
|
61
65
|
const deadlineFor = (timeout) => new Deadline(context.operation(timeout ?? context.timeouts.assertion).timeoutMs);
|
|
62
|
-
|
|
63
|
-
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());
|
|
64
70
|
const expectation = createBrowserExpectation({ currentUrl, currentTitle, baseHref, deadlineFor, context });
|
|
65
71
|
const browser = {
|
|
66
72
|
goto(url, options) {
|
|
@@ -82,8 +88,8 @@ export function createBrowserFixture(surface, context) {
|
|
|
82
88
|
forward: (options) => navigation(options, async (operation) => {
|
|
83
89
|
await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
84
90
|
}),
|
|
85
|
-
url: currentUrl,
|
|
86
|
-
title: currentTitle,
|
|
91
|
+
url: () => currentUrl(),
|
|
92
|
+
title: () => currentTitle(),
|
|
87
93
|
// The same poll as `expect(browser).toHaveURL`, exposed as a wait.
|
|
88
94
|
waitForURL: (url, options) => {
|
|
89
95
|
rejectUnknownOptions('browser.waitForURL', options, ['timeout']);
|
|
@@ -103,6 +109,10 @@ export function createBrowserFixture(surface, context) {
|
|
|
103
109
|
validateJsonValue(result.value, 'evaluate result');
|
|
104
110
|
return result.value;
|
|
105
111
|
},
|
|
112
|
+
async addInitScript(script, ...rest) {
|
|
113
|
+
const source = await testInitScriptSource(script, rest.length === 0 ? undefined : { arg: rest[0] }, (file) => surface.projectPath(file));
|
|
114
|
+
await surface.guard(context.operation(), 'addInitScript', () => surface.addInitScript(source));
|
|
115
|
+
},
|
|
106
116
|
route(pattern, handler) {
|
|
107
117
|
const wirePattern = toTextPattern(pattern);
|
|
108
118
|
const predicate = (url) => routePatternMatches(wirePattern, url.href);
|
|
@@ -117,39 +127,62 @@ export function createBrowserFixture(surface, context) {
|
|
|
117
127
|
}
|
|
118
128
|
decided = true;
|
|
119
129
|
};
|
|
120
|
-
|
|
130
|
+
// Validation runs before `decide`, so a rejected option leaves the
|
|
131
|
+
// request to the abort below. A Playwright call that fails after the
|
|
132
|
+
// decision aborts too, rather than leaving the page's request pending.
|
|
133
|
+
const decideWith = async (name, act) => {
|
|
134
|
+
decide(name);
|
|
135
|
+
try {
|
|
136
|
+
await act();
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
await route.abort().catch(() => undefined);
|
|
140
|
+
throw cause;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const request = route.request();
|
|
144
|
+
const postData = request.postData();
|
|
121
145
|
const publicRoute = {
|
|
122
146
|
request: {
|
|
123
|
-
url:
|
|
124
|
-
method:
|
|
125
|
-
headers:
|
|
147
|
+
url: request.url(),
|
|
148
|
+
method: request.method(),
|
|
149
|
+
headers: request.headers(),
|
|
126
150
|
...(postData === null ? {} : { postData }),
|
|
127
151
|
},
|
|
128
152
|
fulfill: async (response) => {
|
|
129
|
-
|
|
130
|
-
await route.fulfill({
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
153
|
+
const decision = parseFulfill(response, (file) => surface.projectPath(file));
|
|
154
|
+
await decideWith('fulfill', () => route.fulfill({
|
|
155
|
+
...decision,
|
|
156
|
+
...(decision.json === undefined && decision.path === undefined && decision.body === undefined
|
|
157
|
+
? { body: '' }
|
|
158
|
+
: {}),
|
|
159
|
+
}));
|
|
160
|
+
},
|
|
161
|
+
continue: async (overrides) => {
|
|
162
|
+
const { headers, ...decision } = parseContinue(overrides, request.url(), context.app.resolveUrl);
|
|
163
|
+
// `continue` skips every route registered before this one, the
|
|
164
|
+
// site-header route included, so it merges those headers itself.
|
|
165
|
+
const site = surface.siteHeaders(decision.url ?? request.url());
|
|
166
|
+
await decideWith('continue', () => route.continue({
|
|
167
|
+
...decision,
|
|
168
|
+
...(headers === undefined && site === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { headers: { ...lowercaseNames(headers ?? request.headers()), ...site } }),
|
|
171
|
+
}));
|
|
139
172
|
},
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await route.fallback();
|
|
173
|
+
fallback: async (...args) => {
|
|
174
|
+
requireNoArguments('route.fallback', args);
|
|
175
|
+
await decideWith('fallback', () => route.fallback());
|
|
143
176
|
},
|
|
144
|
-
abort: async () => {
|
|
145
|
-
|
|
146
|
-
await route.abort();
|
|
177
|
+
abort: async (...args) => {
|
|
178
|
+
requireNoArguments('route.abort', args);
|
|
179
|
+
await decideWith('abort', () => route.abort());
|
|
147
180
|
},
|
|
148
181
|
};
|
|
149
182
|
try {
|
|
150
183
|
await handler(publicRoute);
|
|
151
184
|
if (!decided) {
|
|
152
|
-
throw new TestError('ACTION_FAILED', 'route handler returned without calling fulfill, continue, or abort');
|
|
185
|
+
throw new TestError('ACTION_FAILED', 'route handler returned without calling fulfill, continue, fallback, or abort');
|
|
153
186
|
}
|
|
154
187
|
}
|
|
155
188
|
catch (cause) {
|
|
@@ -189,13 +222,22 @@ export function createBrowserFixture(surface, context) {
|
|
|
189
222
|
const operation = context.operation(options?.timeout);
|
|
190
223
|
return surface.guard(operation, 'waitForResponse', async (currentOperation) => {
|
|
191
224
|
const response = await surface.requirePage().waitForResponse((candidate) => routePatternMatches(wirePattern, candidate.url()), { timeout: currentOperation.timeoutMs });
|
|
192
|
-
|
|
225
|
+
// Started now, while the browser still holds the body, and awaited
|
|
226
|
+
// only by `text` and `json` on a budget of their own: the timeout
|
|
227
|
+
// bounds the match, never a body still streaming in behind headers
|
|
228
|
+
// that already arrived.
|
|
229
|
+
const body = readResponseBody(response);
|
|
230
|
+
const text = async () => {
|
|
231
|
+
const { signal, timeoutMs } = context.operation();
|
|
232
|
+
const read = await raceAbort(withTimeout(body, timeoutMs, () => new TestError('ACTION_FAILED', `waitForResponse: response body did not finish within ${timeoutMs}ms`)), signal, 'waitForResponse body');
|
|
233
|
+
return read();
|
|
234
|
+
};
|
|
193
235
|
return {
|
|
194
236
|
url: response.url(),
|
|
195
237
|
status: response.status(),
|
|
196
238
|
headers: response.headers(),
|
|
197
|
-
json: async () => JSON.parse(await
|
|
198
|
-
text
|
|
239
|
+
json: async () => JSON.parse(await text()),
|
|
240
|
+
text,
|
|
199
241
|
};
|
|
200
242
|
});
|
|
201
243
|
},
|
|
@@ -215,17 +257,24 @@ export function createBrowserFixture(surface, context) {
|
|
|
215
257
|
setCookies(cookies) {
|
|
216
258
|
const scheme = new URL(baseHref()).protocol === 'https:' ? 'https' : 'http';
|
|
217
259
|
// The harness's URL rule decides which cookie targets are admitted; a
|
|
218
|
-
// domain cookie is checked as the origin it would be sent to.
|
|
219
|
-
for
|
|
220
|
-
|
|
221
|
-
|
|
260
|
+
// domain cookie is checked as the origin it would be sent to. The rule
|
|
261
|
+
// admits `about:blank` for navigation, which holds no cookie. A `url`
|
|
262
|
+
// cookie is set on the URL the rule resolved, so a relative one lands
|
|
263
|
+
// on the base URL the way `goto` would.
|
|
264
|
+
const targets = cookies.map((cookie) => {
|
|
265
|
+
const target = context.app.resolveUrl(cookie.url === undefined ? `${scheme}://${cookie.domain.replace(/^\./, '')}` : cookie.url);
|
|
266
|
+
if (!/^https?:/.test(target)) {
|
|
267
|
+
throw new ConfigurationError('POLICY_DENIED', `cookie URL must be http(s): ${target}`);
|
|
268
|
+
}
|
|
269
|
+
return cookie.url === undefined
|
|
270
|
+
? { domain: cookie.domain, path: cookie.path ?? '/' }
|
|
271
|
+
: { url: target };
|
|
272
|
+
});
|
|
222
273
|
return surface.guard(context.operation(), 'setCookies', async () => {
|
|
223
|
-
await surface.requireContext().addCookies(cookies.map((cookie) => ({
|
|
274
|
+
await surface.requireContext().addCookies(cookies.map((cookie, index) => ({
|
|
224
275
|
name: cookie.name,
|
|
225
276
|
value: cookie.value,
|
|
226
|
-
...
|
|
227
|
-
? { domain: cookie.domain, path: cookie.path ?? '/' }
|
|
228
|
-
: { url: cookie.url }),
|
|
277
|
+
...targets[index],
|
|
229
278
|
...(cookie.expires !== undefined ? { expires: cookie.expires } : {}),
|
|
230
279
|
...(cookie.httpOnly !== undefined ? { httpOnly: cookie.httpOnly } : {}),
|
|
231
280
|
...(cookie.secure !== undefined ? { secure: cookie.secure } : {}),
|
|
@@ -283,17 +332,17 @@ export function createBrowserFixture(surface, context) {
|
|
|
283
332
|
return new TestError('ACTION_FAILED', `no download started within ${awaiting.waitMs}ms; the trigger resolved after ${awaiting.triggerMs}ms`, { cause });
|
|
284
333
|
}
|
|
285
334
|
return translated;
|
|
286
|
-
});
|
|
335
|
+
}, 'test-code');
|
|
287
336
|
},
|
|
288
337
|
keyboard: {
|
|
289
|
-
press: (key) => surface.guard(context.operation(), 'keyboard.press', () => surface.requirePage().keyboard.press(key)),
|
|
290
|
-
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),
|
|
291
340
|
},
|
|
292
341
|
mouse: {
|
|
293
|
-
move: (x, y) => surface.guard(context.operation(), 'mouse.move', () => surface.requirePage().mouse.move(x, y)),
|
|
294
|
-
wheel: (deltaX, deltaY) => surface.guard(context.operation(), 'mouse.wheel', () => surface.requirePage().mouse.wheel(deltaX, deltaY)),
|
|
295
|
-
down: () => surface.guard(context.operation(), 'mouse.down', () => surface.requirePage().mouse.down()),
|
|
296
|
-
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),
|
|
297
346
|
},
|
|
298
347
|
};
|
|
299
348
|
const action = { kind: 'resource' };
|
|
@@ -313,6 +362,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
313
362
|
title: action,
|
|
314
363
|
waitForURL: { ...navigationCall, verifies: true, label: (url) => String(url) },
|
|
315
364
|
evaluate: action,
|
|
365
|
+
addInitScript: { ...action, label: (script) => initScriptLabel(script) },
|
|
316
366
|
route: { ...action, label: (pattern) => String(pattern) },
|
|
317
367
|
unroute: { ...action, label: (pattern) => String(pattern) },
|
|
318
368
|
waitForResponse: { ...action, timeout: (_pattern, options) => options?.timeout, label: (pattern) => String(pattern) },
|
|
@@ -325,61 +375,74 @@ export function createBrowserFixture(surface, context) {
|
|
|
325
375
|
mouse: { move: action, wheel: action, down: action, up: action },
|
|
326
376
|
});
|
|
327
377
|
}
|
|
328
|
-
/**
|
|
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
|
+
*/
|
|
329
384
|
function createBrowserExpectation(deps, negated = false) {
|
|
330
|
-
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;
|
|
331
389
|
await pollCondition({
|
|
332
|
-
deadline
|
|
390
|
+
deadline,
|
|
333
391
|
signal: deps.context.signal,
|
|
334
392
|
negated,
|
|
335
|
-
evaluate:
|
|
336
|
-
|
|
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 }),
|
|
337
398
|
});
|
|
338
399
|
};
|
|
339
|
-
const validate = (api, options) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options,
|
|
400
|
+
const validate = (api, options, keys = ['timeout']) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options, keys);
|
|
340
401
|
return {
|
|
341
402
|
get not() {
|
|
342
403
|
return createBrowserExpectation(deps, !negated);
|
|
343
404
|
},
|
|
344
405
|
toHaveURL(expected, options) {
|
|
345
|
-
validate('toHaveURL', options);
|
|
406
|
+
validate('toHaveURL', options, ['ignoreCase', 'timeout']);
|
|
407
|
+
const ignoreCase = options?.ignoreCase;
|
|
408
|
+
if (ignoreCase !== undefined && typeof ignoreCase !== 'boolean') {
|
|
409
|
+
throw new TestError('INVALID_ARGUMENT', `expect.${negated ? 'not.' : ''}toHaveURL option ignoreCase must be a boolean`);
|
|
410
|
+
}
|
|
346
411
|
const label = typeof expected === 'string' ? expected : String(expected);
|
|
347
412
|
const target = deps.baseHref();
|
|
348
|
-
return poll('toHaveURL', `URL ${label}
|
|
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);
|
|
349
417
|
},
|
|
350
418
|
toHaveTitle(expected, options) {
|
|
351
419
|
validate('toHaveTitle', options);
|
|
352
420
|
const pattern = toTextPattern(expected, { exact: true });
|
|
353
|
-
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);
|
|
354
425
|
},
|
|
355
426
|
toHaveClass(target, expected, options) {
|
|
356
427
|
validate('toHaveClass', options);
|
|
357
428
|
const pattern = toTextPattern(expected, { exact: true });
|
|
358
|
-
return poll('toHaveClass', `class ${describePattern(pattern)}`, async () => {
|
|
359
|
-
|
|
360
|
-
const value = await target.getAttribute('class');
|
|
361
|
-
if (value === null)
|
|
362
|
-
return false;
|
|
363
|
-
const normalized = value.trim().split(/\s+/).join(' ');
|
|
364
|
-
return matchesText(normalized, pattern);
|
|
365
|
-
}
|
|
366
|
-
catch (error) {
|
|
367
|
-
if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
|
|
368
|
-
return undefined;
|
|
369
|
-
throw error;
|
|
370
|
-
}
|
|
371
|
-
}, async () => {
|
|
429
|
+
return poll('toHaveClass', `class ${describePattern(pattern)}`, async (deadline) => {
|
|
430
|
+
let value;
|
|
372
431
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return 'no class attribute';
|
|
376
|
-
return `class ${JSON.stringify(value)}`;
|
|
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 }));
|
|
377
434
|
}
|
|
378
435
|
catch (error) {
|
|
379
436
|
if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
|
|
380
|
-
return 'no node';
|
|
437
|
+
return { matches: undefined, observed: 'no node' };
|
|
381
438
|
throw error;
|
|
382
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
|
+
};
|
|
383
446
|
}, options?.timeout);
|
|
384
447
|
},
|
|
385
448
|
};
|
package/dist/capture-scope.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Deadline and handle ownership for one observation capture operation. */
|
|
2
|
-
import type { JSHandle } from 'playwright';
|
|
2
|
+
import type { JSHandle } from 'playwright-core';
|
|
3
3
|
/** Releases acquired and late-arriving handles without waiting on the transport. */
|
|
4
4
|
export declare class CaptureScope {
|
|
5
5
|
readonly deadline: number;
|
package/dist/cdp-recovery.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Validates a persistent CDP binding without publishing or retaining it. */
|
|
2
|
-
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
2
|
+
import type { Browser, BrowserContext, Page } from 'playwright-core';
|
|
3
3
|
import { EngineError } from 'e2e/engine';
|
|
4
4
|
import { type ConnectionBudget } from './operation-budget.ts';
|
|
5
5
|
export type CdpEndpointResolver = (signal: AbortSignal) => string | Promise<string>;
|
package/dist/cdp-selectors.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Registers the engine's selector engines in a CDP default context; every locator and secure-field mask needs them. */
|
|
2
|
-
import type { BrowserContext, Page } from 'playwright';
|
|
2
|
+
import type { BrowserContext, Page } from 'playwright-core';
|
|
3
3
|
import { type ConnectionBudget } from './operation-budget.ts';
|
|
4
4
|
/**
|
|
5
5
|
* Playwright 1.63 seeds custom selectors only in newly created contexts, not
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The `e2e-web` command: browser installs with the Playwright version this engine pins. */
|
|
2
|
+
/** What `main` writes to and spawns; tests replace them. */
|
|
3
|
+
export interface CliIo {
|
|
4
|
+
readonly stdout: (text: string) => void;
|
|
5
|
+
readonly stderr: (text: string) => void;
|
|
6
|
+
readonly run: (args: readonly string[]) => Promise<number>;
|
|
7
|
+
}
|
|
8
|
+
/** Runs `e2e-web` with the arguments after the command name and resolves to the exit code. */
|
|
9
|
+
export declare function main(args: readonly string[], io?: CliIo): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** The `e2e-web` command: browser installs with the Playwright version this engine pins. */
|
|
2
|
+
import { BROWSER_NAMES, isBrowserName } from './browser-connection.js';
|
|
3
|
+
import { runPlaywrightCli } from './install.js';
|
|
4
|
+
const BROWSER_ARGS = `[${BROWSER_NAMES.join('|')} ...] [--with-deps]`;
|
|
5
|
+
const USAGE = `Usage: npx @e2e-dev/web install ${BROWSER_ARGS}
|
|
6
|
+
bunx @e2e-dev/web install ${BROWSER_ARGS}
|
|
7
|
+
pnpm exec e2e-web install ${BROWSER_ARGS}
|
|
8
|
+
|
|
9
|
+
Installs browsers for the Playwright version @e2e-dev/web runs. Installs
|
|
10
|
+
chromium when no browser is named. --with-deps also installs the system
|
|
11
|
+
libraries the browsers need on Linux.
|
|
12
|
+
`;
|
|
13
|
+
const PROCESS_IO = {
|
|
14
|
+
stdout: (text) => process.stdout.write(text),
|
|
15
|
+
stderr: (text) => process.stderr.write(text),
|
|
16
|
+
run: runPlaywrightCli,
|
|
17
|
+
};
|
|
18
|
+
/** Runs `e2e-web` with the arguments after the command name and resolves to the exit code. */
|
|
19
|
+
export async function main(args, io = PROCESS_IO) {
|
|
20
|
+
const [command, ...rest] = args;
|
|
21
|
+
if (command === 'help' || args.some((arg) => arg === '--help' || arg === '-h')) {
|
|
22
|
+
io.stdout(USAGE);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (command !== 'install') {
|
|
26
|
+
io.stderr(`${command === undefined ? 'missing command' : `unknown command "${command}"`}\n\n${USAGE}`);
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
const options = rest.filter((arg) => arg.startsWith('-'));
|
|
30
|
+
const names = rest.filter((arg) => !arg.startsWith('-'));
|
|
31
|
+
const unknownOption = options.find((option) => option !== '--with-deps');
|
|
32
|
+
if (unknownOption !== undefined) {
|
|
33
|
+
io.stderr(`unknown option "${unknownOption}"\n\n${USAGE}`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
const unknownBrowser = names.find((name) => !isBrowserName(name));
|
|
37
|
+
if (unknownBrowser !== undefined) {
|
|
38
|
+
io.stderr(`unknown browser "${unknownBrowser}"; expected one of ${BROWSER_NAMES.join(', ')}\n`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
return io.run(['install', ...options, ...(names.length > 0 ? names : ['chromium'])]);
|
|
42
|
+
}
|
package/dist/dialogs.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* fixture; the surface owns the router so a dialog that fires before a test
|
|
4
4
|
* ever touches `web` is still accounted for instead of silently dismissed.
|
|
5
5
|
*/
|
|
6
|
-
import type { Dialog as PwDialog } from 'playwright';
|
|
6
|
+
import type { Dialog as PwDialog } from 'playwright-core';
|
|
7
7
|
import { ErrorLatch } from './support.ts';
|
|
8
8
|
/** A native dialog as a test's handler sees it. */
|
|
9
9
|
export interface Dialog {
|
package/dist/downloads.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* that serves downloads names a directory on the browser's own disk; the
|
|
6
6
|
* browser saves there, and the provider reads each finished file back.
|
|
7
7
|
*/
|
|
8
|
-
import type { Download, Page } from 'playwright';
|
|
8
|
+
import type { Download, Page } from 'playwright-core';
|
|
9
9
|
import type { LeaseDownloads } from './provider.ts';
|
|
10
10
|
/**
|
|
11
11
|
* Has the browser behind `page` save the downloads of the page's context
|
package/dist/engine.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* package imports the contract from `e2e/engine` and contributes the `browser`
|
|
6
6
|
* fixture the way a device engine contributes `device`.
|
|
7
7
|
*/
|
|
8
|
-
import type { BrowserContext, Page } from 'playwright';
|
|
8
|
+
import type { BrowserContext, Page } from 'playwright-core';
|
|
9
9
|
import { type EngineHandle } from 'e2e/engine';
|
|
10
10
|
import { type WebOptions } from './surface.ts';
|
|
11
11
|
/**
|
package/dist/engine.js
CHANGED
|
@@ -10,6 +10,8 @@ import { createRequire } from 'node:module';
|
|
|
10
10
|
import { asBrowserProvider } from './provider.js';
|
|
11
11
|
import { PlaywrightSurface } from './surface.js';
|
|
12
12
|
import { createBrowserFixture } from './browser.js';
|
|
13
|
+
import { validateInitScripts } from './init-scripts.js';
|
|
14
|
+
import { FIELD_VALUE_CONTROL, headerProblem } from './protected-app.js';
|
|
13
15
|
/** App fields only a device target reads. */
|
|
14
16
|
const NATIVE_APP_KEYS = ['bundleId', 'appPath', 'launchArguments', 'permissions'];
|
|
15
17
|
/** Creates one Playwright engine: one browser per worker, one context per attempt. */
|
|
@@ -46,6 +48,12 @@ export function web(options = {}) {
|
|
|
46
48
|
validateTestIdAttribute(options.testIdAttribute);
|
|
47
49
|
if (options.userAgent !== undefined)
|
|
48
50
|
validateUserAgent(options.userAgent, options.headers);
|
|
51
|
+
if (options.locale !== undefined)
|
|
52
|
+
validateLocale(options.locale, options.headers);
|
|
53
|
+
if (options.timezoneId !== undefined)
|
|
54
|
+
validateTimezoneId(options.timezoneId);
|
|
55
|
+
if (options.initScripts !== undefined)
|
|
56
|
+
validateInitScripts(options.initScripts);
|
|
49
57
|
if (options.screencast !== undefined)
|
|
50
58
|
validateScreencast(options.screencast);
|
|
51
59
|
const reconnecting = options.connect?.reconnectEndpoint !== undefined;
|
|
@@ -55,8 +63,9 @@ export function web(options = {}) {
|
|
|
55
63
|
// A per-attempt lease rides the same persistent context as `reconnectEndpoint`, with the same limits.
|
|
56
64
|
const recoverable = reconnecting || provider?.scope === 'attempt';
|
|
57
65
|
const mode = provider === undefined ? 'connect.reconnectEndpoint' : `browser provider "${provider.name}" with scope "attempt"`;
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
const creationOptions = CREATION_OPTION_KEYS.filter((key) => options[key] !== undefined);
|
|
67
|
+
if (recoverable && creationOptions.length > 0) {
|
|
68
|
+
throw new ConfigurationError('INVALID_CONFIG', `${mode} uses a persistent context; ${creationOptions.join(', ')} ${creationOptions.length === 1 ? 'requires' : 'require'} a newly created context`);
|
|
60
69
|
}
|
|
61
70
|
const surface = new PlaywrightSurface(options);
|
|
62
71
|
const handle = defineEngine({
|
|
@@ -125,7 +134,12 @@ const WEB_OPTION_KEYS = Object.keys({
|
|
|
125
134
|
basicAuth: true,
|
|
126
135
|
testIdAttribute: true,
|
|
127
136
|
userAgent: true,
|
|
137
|
+
locale: true,
|
|
138
|
+
timezoneId: true,
|
|
139
|
+
initScripts: true,
|
|
128
140
|
});
|
|
141
|
+
/** The options applied when the engine creates a browser context, which a persistent context never is. */
|
|
142
|
+
const CREATION_OPTION_KEYS = ['headers', 'basicAuth', 'userAgent', 'locale', 'timezoneId'];
|
|
129
143
|
/**
|
|
130
144
|
* What a persistent context rules out, named with its cause: `what` replaces
|
|
131
145
|
* the browser context, which `mode` never does. A per-attempt lease starts
|
|
@@ -135,11 +149,6 @@ function persistentContextLimit(what, mode, leased) {
|
|
|
135
149
|
const remedy = leased ? '; every attempt already starts on a fresh browser, and scope "worker" can clear one mid-test' : '';
|
|
136
150
|
return new ConfigurationError('UNSUPPORTED_CAPABILITY', `${what} is unavailable with ${mode}: it replaces the browser context, and the attempt rides one persistent context${remedy}`);
|
|
137
151
|
}
|
|
138
|
-
/** An HTTP header field name: one or more `token` characters (RFC 9110). */
|
|
139
|
-
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
140
|
-
/** A control character no HTTP field value may carry; a horizontal tab is the one the grammar allows. */
|
|
141
|
-
// oxlint-disable-next-line no-control-regex -- the control characters are the point
|
|
142
|
-
const FIELD_VALUE_CONTROL = /[\u0000-\u0008\u000A-\u001F\u007F]/;
|
|
143
152
|
/** True for a plain object; the shape both options take. Config runs as JavaScript, so the types alone are no guard. */
|
|
144
153
|
function isRecord(value) {
|
|
145
154
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -155,15 +164,9 @@ function validateHeaders(headers) {
|
|
|
155
164
|
throw new ConfigurationError('INVALID_CONFIG', 'web({ headers }) must be an object of header name to value');
|
|
156
165
|
}
|
|
157
166
|
for (const [name, value] of Object.entries(headers)) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (typeof value !== 'string') {
|
|
162
|
-
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must be a string, got ${typeof value}`);
|
|
163
|
-
}
|
|
164
|
-
if (FIELD_VALUE_CONTROL.test(value)) {
|
|
165
|
-
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must not contain a control character`);
|
|
166
|
-
}
|
|
167
|
+
const problem = headerProblem(name, value);
|
|
168
|
+
if (problem !== undefined)
|
|
169
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) ${problem}`);
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
172
|
/** Refuses credentials the browser could not present: a missing field, or a `:` in the user name (RFC 7617). */
|
|
@@ -199,6 +202,61 @@ function validateUserAgent(userAgent, headers) {
|
|
|
199
202
|
throw new ConfigurationError('INVALID_CONFIG', 'web({ userAgent }) and a user-agent header in web({ headers }) conflict; set userAgent only');
|
|
200
203
|
}
|
|
201
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Refuses a locale that is not a BCP 47 tag, and one an `accept-language`
|
|
207
|
+
* header would override on the app's site while `navigator.language` kept
|
|
208
|
+
* reporting it.
|
|
209
|
+
*/
|
|
210
|
+
function validateLocale(locale, headers) {
|
|
211
|
+
if (typeof locale !== 'string' || locale === '' || !isLanguageTag(locale)) {
|
|
212
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ locale }) must be a BCP 47 language tag such as "de-DE", got ${JSON.stringify(locale)}`);
|
|
213
|
+
}
|
|
214
|
+
if (headers !== undefined && Object.keys(headers).some((name) => name.toLowerCase() === 'accept-language')) {
|
|
215
|
+
throw new ConfigurationError('INVALID_CONFIG', 'web({ locale }) and an accept-language header in web({ headers }) conflict; set locale only');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True when `tag` names a language the browser can run in: a Unicode locale
|
|
220
|
+
* `Intl.Locale` parses, other than the undetermined `und`, which Chromium
|
|
221
|
+
* refuses at context creation as it does a private-use-only tag.
|
|
222
|
+
*/
|
|
223
|
+
function isLanguageTag(tag) {
|
|
224
|
+
try {
|
|
225
|
+
const { language } = new Intl.Locale(tag);
|
|
226
|
+
return language !== undefined && language !== 'und';
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Refuses, at config load, a time zone the browser would otherwise refuse at
|
|
234
|
+
* the first context. Best effort: Node cannot tell a miscased alias
|
|
235
|
+
* (`us/eastern`) from the real one, so the browser's own `Invalid timezone ID`
|
|
236
|
+
* stays the final word.
|
|
237
|
+
*/
|
|
238
|
+
function validateTimezoneId(timezoneId) {
|
|
239
|
+
if (typeof timezoneId !== 'string' || timezoneId === '' || !isTimeZone(timezoneId)) {
|
|
240
|
+
throw new ConfigurationError('INVALID_CONFIG', `web({ timezoneId }) must be an IANA time zone such as "Europe/Berlin", got ${JSON.stringify(timezoneId)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* True when `timeZone` is an IANA name `Intl` resolves, spelled with its own
|
|
245
|
+
* case. Node also takes an offset (`+01:00`) and any casing (`europe/berlin`),
|
|
246
|
+
* both of which Chromium refuses; an alias resolving to another name
|
|
247
|
+
* (`US/Eastern`) is fine.
|
|
248
|
+
*/
|
|
249
|
+
function isTimeZone(timeZone) {
|
|
250
|
+
if (/^[+-]/.test(timeZone))
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
const resolved = new Intl.DateTimeFormat('en-US', { timeZone }).resolvedOptions().timeZone;
|
|
254
|
+
return resolved === timeZone || resolved.toLowerCase() !== timeZone.toLowerCase();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
202
260
|
/**
|
|
203
261
|
* An attribute name the document grammar accepts (XML `Name`, ASCII): a value
|
|
204
262
|
* outside it could never be on an element, so `getByTestId` would match nothing
|
package/dist/evaluation.d.ts
CHANGED
|
@@ -5,13 +5,18 @@ type EvaluationResult = {
|
|
|
5
5
|
ok: false;
|
|
6
6
|
message: string;
|
|
7
7
|
};
|
|
8
|
+
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
9
|
+
export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
8
10
|
/**
|
|
9
11
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
12
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
13
|
+
* other value is the result.
|
|
10
14
|
*
|
|
11
15
|
* A function serialized with `toString()` carries whatever the loader compiled
|
|
12
|
-
* it to.
|
|
13
|
-
* binding in a module-scoped `__name(target, name)`
|
|
14
|
-
* 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.
|
|
15
20
|
*/
|
|
16
21
|
export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
|
|
17
22
|
export {};
|
package/dist/evaluation.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
|
|
2
2
|
import { TestError } from 'e2e/engine';
|
|
3
3
|
import { message } from './support.js';
|
|
4
|
+
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
5
|
+
export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
4
6
|
/**
|
|
5
7
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
8
|
+
* The source is one expression: a function it evaluates to is called with the argument, any
|
|
9
|
+
* other value is the result.
|
|
6
10
|
*
|
|
7
11
|
* A function serialized with `toString()` carries whatever the loader compiled
|
|
8
|
-
* it to.
|
|
9
|
-
* binding in a module-scoped `__name(target, name)`
|
|
10
|
-
* 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.
|
|
11
16
|
*/
|
|
12
17
|
export function compileEvaluation(source, hasArgument) {
|
|
13
18
|
try {
|
|
14
19
|
return new Function('arg', `
|
|
15
20
|
return (async () => {
|
|
16
|
-
|
|
21
|
+
${KEEP_NAMES_HELPER}
|
|
17
22
|
try {
|
|
18
|
-
return { ok: true, value: await (
|
|
23
|
+
return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
|
|
19
24
|
} catch (cause) {
|
|
20
25
|
let message;
|
|
21
26
|
try {
|