@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.
Files changed (53) hide show
  1. package/README.md +15 -9
  2. package/dist/.build.json +1 -1
  3. package/dist/actions.d.ts +9 -3
  4. package/dist/actions.js +87 -9
  5. package/dist/attempt-session.d.ts +4 -3
  6. package/dist/attempt-session.js +17 -14
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +3 -0
  9. package/dist/browser-connection.d.ts +7 -3
  10. package/dist/browser-connection.js +7 -1
  11. package/dist/browser.d.ts +44 -7
  12. package/dist/browser.js +135 -72
  13. package/dist/capture-scope.d.ts +1 -1
  14. package/dist/cdp-recovery.d.ts +1 -1
  15. package/dist/cdp-selectors.d.ts +1 -1
  16. package/dist/cli.d.ts +9 -0
  17. package/dist/cli.js +42 -0
  18. package/dist/dialogs.d.ts +1 -1
  19. package/dist/downloads.d.ts +1 -1
  20. package/dist/engine.d.ts +1 -1
  21. package/dist/engine.js +74 -16
  22. package/dist/evaluation.d.ts +8 -3
  23. package/dist/evaluation.js +10 -5
  24. package/dist/in-page/read-semantics.d.ts +1 -0
  25. package/dist/in-page/read-semantics.js +166 -40
  26. package/dist/index.d.ts +2 -1
  27. package/dist/init-scripts.d.ts +30 -0
  28. package/dist/init-scripts.js +105 -0
  29. package/dist/install.d.ts +14 -1
  30. package/dist/install.js +77 -20
  31. package/dist/locators.d.ts +1 -1
  32. package/dist/locators.js +30 -18
  33. package/dist/observation-capture.d.ts +1 -1
  34. package/dist/observation.d.ts +1 -1
  35. package/dist/observation.js +3 -3
  36. package/dist/observe.d.ts +1 -1
  37. package/dist/operation-budget.d.ts +14 -0
  38. package/dist/operation-budget.js +31 -0
  39. package/dist/protected-app.d.ts +20 -4
  40. package/dist/protected-app.js +35 -4
  41. package/dist/provider-video.d.ts +1 -1
  42. package/dist/read-node.d.ts +9 -2
  43. package/dist/route-options.d.ts +44 -0
  44. package/dist/route-options.js +125 -0
  45. package/dist/selector-engines.js +2 -0
  46. package/dist/shown-selector.d.ts +20 -0
  47. package/dist/shown-selector.js +27 -0
  48. package/dist/support.d.ts +18 -12
  49. package/dist/support.js +41 -20
  50. package/dist/surface.d.ts +46 -6
  51. package/dist/surface.js +60 -19
  52. package/dist/video.d.ts +1 -1
  53. 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
- const currentUrl = () => surface.guard(context.operation(), 'url', async () => surface.requirePage().url());
63
- const currentTitle = () => surface.guard(context.operation(), 'title', () => surface.requirePage().title());
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
- const postData = route.request().postData();
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: route.request().url(),
124
- method: route.request().method(),
125
- headers: route.request().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
- decide('fulfill');
130
- await route.fulfill({
131
- status: response.status ?? 200,
132
- headers: response.headers ?? {},
133
- ...('json' in response && response.json !== undefined
134
- ? { json: response.json }
135
- : 'body' in response && response.body !== undefined
136
- ? { body: response.body }
137
- : { body: '' }),
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
- continue: async () => {
141
- decide('continue');
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
- decide('abort');
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
- const body = await readResponseBody(response);
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 body()),
198
- text: body,
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 (const cookie of cookies) {
220
- context.app.resolveUrl(cookie.url === undefined ? `${scheme}://${cookie.domain.replace(/^\./, '')}` : cookie.url);
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
- ...(cookie.url === undefined
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
- /** `expect(browser)` matchers: URL, title, and class polling against the assertion budget. */
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, condition, observed, timeout) => {
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: deps.deadlineFor(timeout),
390
+ deadline,
333
391
  signal: deps.context.signal,
334
392
  negated,
335
- evaluate: condition,
336
- onTimeout: async () => new TestError('ASSERTION_FAILED', `expect.${negated ? 'not.' : ''}${api} failed\nexpected: ${negated ? 'not ' : ''}${label}\nobserved: ${await observed()}`),
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, ['timeout']);
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}`, async () => urlMatches(await deps.currentUrl(), expected, target), async () => `URL ${await deps.currentUrl()}`, options?.timeout);
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 () => matchesText(await deps.currentTitle(), pattern), async () => `title ${JSON.stringify(await deps.currentTitle())}`, options?.timeout);
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
- try {
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
- const value = await target.getAttribute('class');
374
- if (value === null)
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
  };
@@ -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;
@@ -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>;
@@ -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 {
@@ -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
- if (recoverable && (options.headers !== undefined || options.basicAuth !== undefined || options.userAgent !== undefined)) {
59
- throw new ConfigurationError('INVALID_CONFIG', `${mode} uses a persistent context; headers, basicAuth, and userAgent require a newly created context`);
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
- if (!HEADER_NAME.test(name)) {
159
- throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) has an invalid header name: "${name}"`);
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
@@ -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. tsx runs esbuild with `keepNames`, which wraps every nested named
13
- * binding in a module-scoped `__name(target, name)` helper; the page has no
14
- * such helper, so the same one is declared next to the inlined source.
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 {};
@@ -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. tsx runs esbuild with `keepNames`, which wraps every nested named
9
- * binding in a module-scoped `__name(target, name)` helper; the page has no
10
- * such helper, so the same one is declared next to the inlined source.
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
- const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });
21
+ ${KEEP_NAMES_HELPER}
17
22
  try {
18
- return { ok: true, value: await (${source}\n)(${hasArgument ? 'arg' : ''}) };
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 {