@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/install.js CHANGED
@@ -1,36 +1,92 @@
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
+ import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { EngineError, InfrastructureError } from 'e2e/engine';
7
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
+ }
8
17
  /**
9
- * Whether the browser's executable exists on disk, or undefined when this
10
- * process cannot tell. Playwright resolves its browser cache from
11
- * `PLAYWRIGHT_BROWSERS_PATH` at module load, so the in-process check is only
12
- * authoritative when the run's environment agrees with this process's. A run
13
- * pointed at another cache is left to the CLI, which resolves against the
14
- * 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.
15
20
  */
16
- function isBrowserInstalled(name, env) {
21
+ function isBrowserInstalled(name, env, headed) {
17
22
  if (env['PLAYWRIGHT_BROWSERS_PATH'] !== process.env['PLAYWRIGHT_BROWSERS_PATH'])
18
23
  return undefined;
19
24
  try {
20
- return existsSync(browserType(name).executablePath());
25
+ const executable = browserType(name).executablePath();
26
+ if (headed || name !== 'chromium')
27
+ return existsSync(executable);
28
+ return headlessShellAt(executable);
21
29
  }
22
30
  catch {
23
31
  return false;
24
32
  }
25
33
  }
26
- /** Resolves the `playwright` CLI entry point relative to this package. */
27
- function playwrightCliPath() {
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
+ }
43
+ /** The `node` arguments that run the pinned `playwright-core` CLI with `args`. */
44
+ function playwrightCliArgs(args) {
28
45
  const require = createRequire(import.meta.url);
29
- return path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
46
+ return [path.join(path.dirname(require.resolve('playwright-core/package.json')), 'cli.js'), ...args];
47
+ }
48
+ /**
49
+ * Runs the pinned Playwright CLI with `args`, sharing this process's terminal,
50
+ * and resolves to its exit code, 128 plus the signal number when a signal
51
+ * ended it. SIGINT and SIGTERM sent to this process reach the child, so a
52
+ * cancelled CI job does not leave a download running. Backs the `e2e-web` command.
53
+ */
54
+ export function runPlaywrightCli(args) {
55
+ return new Promise((resolve, reject) => {
56
+ const child = spawn(process.execPath, playwrightCliArgs(args), {
57
+ stdio: 'inherit',
58
+ env: installEnvironment(),
59
+ });
60
+ const forward = (signal) => child.kill(signal);
61
+ process.on('SIGINT', forward);
62
+ process.on('SIGTERM', forward);
63
+ const settle = () => {
64
+ process.off('SIGINT', forward);
65
+ process.off('SIGTERM', forward);
66
+ };
67
+ child.on('error', (error) => {
68
+ settle();
69
+ reject(error);
70
+ });
71
+ child.on('exit', (code, signal) => {
72
+ settle();
73
+ resolve(code ?? (signal === null ? 1 : 128 + os.constants.signals[signal]));
74
+ });
75
+ });
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' };
30
86
  }
31
87
  /**
32
88
  * Ensures the given Playwright browsers are installed, downloading any
33
- * missing ones via `playwright install`. Called from the engine's `prepare`,
89
+ * missing ones via `playwright-core install`. Called from the engine's `prepare`,
34
90
  * once per run before any worker starts, so a download is never charged
35
91
  * against a launch timeout and never runs once per worker. Concurrent
36
92
  * installs (two runs at once) are still safe: the Playwright CLI serializes
@@ -38,7 +94,8 @@ function playwrightCliPath() {
38
94
  */
39
95
  export async function ensureBrowsersInstalled(names, options = {}) {
40
96
  const env = options.env ?? process.env;
41
- const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env));
97
+ const headed = options.headed ?? false;
98
+ const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env, headed));
42
99
  const verdicts = new Map([...new Set(names)].map((name) => [name, isInstalled(name)]));
43
100
  const missing = [...verdicts.keys()].filter((name) => verdicts.get(name) !== true);
44
101
  if (missing.length === 0)
@@ -51,7 +108,7 @@ export async function ensureBrowsersInstalled(names, options = {}) {
51
108
  if (knownMissing)
52
109
  log(`Downloading missing Playwright browsers (first run): ${missing.join(', ')}...`);
53
110
  const install = options.install ?? runPlaywrightInstall;
54
- await install(missing, { log, signal: options.signal, env });
111
+ await install(missing, { log, signal: options.signal, env: installEnvironment(env), headed });
55
112
  if (knownMissing)
56
113
  log('Browser download complete.');
57
114
  }
@@ -81,14 +138,14 @@ function forwardLines(stream, log) {
81
138
  });
82
139
  stream.on('end', () => flush(pending));
83
140
  }
84
- /** Spawns `node <playwright>/cli.js install <names>`, narrating its output through `log`. */
85
- function runPlaywrightInstall(names, { log, signal, env }) {
141
+ /** Spawns `node <playwright-core>/cli.js install <names>`, narrating its output through `log`. */
142
+ function runPlaywrightInstall(names, { log, signal, env, headed }) {
86
143
  return new Promise((resolve, reject) => {
87
144
  if (signal?.aborted === true) {
88
145
  reject(new EngineError('CANCELLED', 'browser install cancelled', { retryable: false }));
89
146
  return;
90
147
  }
91
- const child = spawn(process.execPath, [playwrightCliPath(), 'install', ...names], {
148
+ const child = spawn(process.execPath, playwrightCliArgs(['install', ...installArgs(names, headed)]), {
92
149
  stdio: ['ignore', 'pipe', 'pipe'],
93
150
  env,
94
151
  });
@@ -101,7 +158,7 @@ function runPlaywrightInstall(names, { log, signal, env }) {
101
158
  signal?.addEventListener('abort', onAbort, { once: true });
102
159
  child.on('exit', () => signal?.removeEventListener('abort', onAbort));
103
160
  child.on('error', (cause) => {
104
- reject(new InfrastructureError('BROWSER_INSTALL_FAILED', `failed to run playwright install: ${cause.message}`, {
161
+ reject(new InfrastructureError('BROWSER_INSTALL_FAILED', `failed to run playwright-core install: ${cause.message}`, {
105
162
  cause,
106
163
  }));
107
164
  });
@@ -110,7 +167,7 @@ function runPlaywrightInstall(names, { log, signal, env }) {
110
167
  resolve();
111
168
  return;
112
169
  }
113
- reject(new InfrastructureError('BROWSER_INSTALL_FAILED', `playwright install ${names.join(' ')} exited with code ${String(code)}; run "npx playwright install ${names.join(' ')}" manually`));
170
+ reject(new InfrastructureError('BROWSER_INSTALL_FAILED', `playwright-core install ${names.join(' ')} exited with code ${String(code)}; run "npx @e2e-dev/web install ${names.join(' ')}" (pnpm: "pnpm exec e2e-web install ${names.join(' ')}") manually`));
114
171
  });
115
172
  });
116
173
  }
@@ -1,5 +1,5 @@
1
1
  /** LocatorExpression -> Playwright locator projection. */
2
- import type { Locator as PwLocator, Page } from 'playwright';
2
+ import type { Locator as PwLocator, Page } from 'playwright-core';
3
3
  import { type LocatorExpression, type TextPattern } from 'e2e/engine';
4
4
  /** One positional step applied after display-value filtering. */
5
5
  export type PositionalStep = 'first' | 'last' | number;
package/dist/locators.js CHANGED
@@ -3,11 +3,27 @@ import { EngineError } from 'e2e/engine';
3
3
  import { SEARCH_ROOTS_SELECTOR_ENGINE } from './closed-shadow.js';
4
4
  import { exactLabelSelector } from './label-selector.js';
5
5
  import { SECURE_FIELD_SELECTOR } from './read-node.js';
6
+ import { SHOWN_SELECTOR } from './shown-selector.js';
6
7
  function patternToPw(pattern) {
7
8
  if (pattern.kind === 'regexp')
8
- return new RegExp(pattern.source, pattern.flags);
9
+ return selectorSafeRegExp(new RegExp(pattern.source, pattern.flags));
9
10
  return pattern.value;
10
11
  }
12
+ /**
13
+ * A RegExp Playwright can write into a selector as is. Playwright escapes the
14
+ * quotes and `>>` of a RegExp it writes into a selector, except under the `u`
15
+ * or `v` flag, where `\'` is a syntax error: a bare quote there opens a
16
+ * string that swallows every part chained after it. A quote or the second `>`
17
+ * of `>>` becomes the hex escape the flag accepts, matching the same text.
18
+ */
19
+ function selectorSafeRegExp(re) {
20
+ if (!/[uv]/.test(re.flags))
21
+ return re;
22
+ const source = re.source
23
+ .replace(/["'`]/g, (quote) => `\\x${quote.charCodeAt(0).toString(16)}`)
24
+ .replace(/>>/g, '>\\x3e');
25
+ return source === re.source ? re : new RegExp(source, re.flags);
26
+ }
11
27
  function patternExact(pattern) {
12
28
  return pattern.kind === 'string' ? pattern.exact : false;
13
29
  }
@@ -21,7 +37,7 @@ function patternExact(pattern) {
21
37
  function testIdSelector(attribute, pattern) {
22
38
  const name = attribute.includes(',') ? JSON.stringify(attribute) : attribute;
23
39
  const value = pattern.kind === 'regexp'
24
- ? escapeRegexForSelector(new RegExp(pattern.source, pattern.flags))
40
+ ? escapeRegexForSelector(selectorSafeRegExp(new RegExp(pattern.source, pattern.flags)))
25
41
  : `"${pattern.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"s`;
26
42
  return `internal:testid=[${name}=${value}]`;
27
43
  }
@@ -39,8 +55,12 @@ function escapeRegexForSelector(re) {
39
55
  * `image` query has to go back to `img` to match it.
40
56
  */
41
57
  const ARIA_ROLE_BY_CONTRACT_ROLE = { image: 'img' };
42
- /** Every control that carries a current value: the candidates of a display-value query. */
43
- const VALUED_SELECTOR = 'input, textarea, select';
58
+ /**
59
+ * Every control that displays a current value: the candidates of a
60
+ * display-value query. A checkbox or radio carries a value it never shows
61
+ * (`on` by default), so it is no candidate.
62
+ */
63
+ const VALUED_SELECTOR = 'input:not([type="checkbox" i]):not([type="radio" i]), textarea, select';
44
64
  /** Whitespace or an icon-font glyph (a private-use code point), the characters the tree's names drop or collapse. */
45
65
  const GLYPH_OR_SPACE = '[\\s\\p{Co}]';
46
66
  /**
@@ -58,7 +78,7 @@ function glyphTolerantName(name, exact) {
58
78
  .split(/\s+/)
59
79
  .map((word) => word.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'))
60
80
  .join(`${GLYPH_OR_SPACE}+`);
61
- return exact ? new RegExp(`^${GLYPH_OR_SPACE}*${body}${GLYPH_OR_SPACE}*$`, 'u') : new RegExp(body, 'iu');
81
+ return selectorSafeRegExp(exact ? new RegExp(`^${GLYPH_OR_SPACE}*${body}${GLYPH_OR_SPACE}*$`, 'u') : new RegExp(body, 'iu'));
62
82
  }
63
83
  /**
64
84
  * The one table of query kinds: each declares its Playwright getter or its
@@ -125,20 +145,12 @@ function queryToPw(scope, query, testIdAttribute) {
125
145
  return candidates.locate(scope.locator(`${SEARCH_ROOTS_SELECTOR_ENGINE}=`));
126
146
  }
127
147
  }
128
- /**
129
- * Self-selector for the part of the semantic `hidden` state Playwright's own
130
- * visibility filter does not read: `aria-hidden` on the element itself.
131
- */
132
- const NOT_ARIA_HIDDEN = ':scope:not([aria-hidden="true"])';
133
148
  /**
134
149
  * A `visible` query narrows its candidates inside the selector, before any
135
- * enclosing scope, filter, or index runs: Playwright's visibility predicate
136
- * (layout box, `display`, `visibility`) plus the `aria-hidden` check the
137
- * semantic `hidden` state also makes. An indexed, filtered, or scoping visible
138
- * query therefore never selects or retains a node that state calls hidden.
139
- * The surface additionally holds a terminal query to the batch-read `hidden`
140
- * state, which reads the same box, style, and `details` facts Playwright's
141
- * predicate does, so a direct query agrees with `toBeVisible()`.
150
+ * enclosing scope, filter, or index runs, by the reader's own `hidden` state
151
+ * (`e2e-shown`). An indexed, filtered, or scoping visible query therefore
152
+ * never selects or retains a node that state calls hidden, and the surface's
153
+ * hold of a terminal query to the batch-read state agrees with it.
142
154
  */
143
155
  function visibleQueryToPw(scope, query, testIdAttribute) {
144
156
  return narrowedToVisible(queryToPw(scope, query, testIdAttribute), query);
@@ -147,7 +159,7 @@ function visibleQueryToPw(scope, query, testIdAttribute) {
147
159
  function narrowedToVisible(located, query) {
148
160
  if (query.visible !== true)
149
161
  return located;
150
- return located.filter({ visible: true }).locator(NOT_ARIA_HIDDEN);
162
+ return located.locator(SHOWN_SELECTOR);
151
163
  }
152
164
  function positioned(locator, index) {
153
165
  return index === 'first' ? locator.first() : index === 'last' ? locator.last() : locator.nth(index);
@@ -1,5 +1,5 @@
1
1
  /** One observation transaction: settling, semantic capture, masked pixels, and staged refs. */
2
- import type { Page } from 'playwright';
2
+ import type { Page } from 'playwright-core';
3
3
  import { type EngineObserveOptions, type OperationContext } from 'e2e/engine';
4
4
  import { RefRegistry, type CapturedObservation } from './refs.ts';
5
5
  interface CaptureSettings {
@@ -2,7 +2,7 @@
2
2
  * Semantic tree capture for one observation: the in-page walk, nested-document
3
3
  * stitching, and element-handle bookkeeping the surface keeps per generation.
4
4
  */
5
- import type { ElementHandle, Frame } from 'playwright';
5
+ import type { ElementHandle, Frame } from 'playwright-core';
6
6
  import { type NodeRef, type SemanticNode } from 'e2e/engine';
7
7
  import { type RawNodeData } from './read-node.ts';
8
8
  /**
@@ -154,7 +154,7 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
154
154
  *
155
155
  * A `data:` document is *not* admitted, even though its bytes are written by the
156
156
  * page that embeds it. It has an opaque origin rather than an inherited one, and
157
- * navigation denies the scheme by name alongside `file:` and `javascript:`.
157
+ * navigation admits only http(s) and `about:blank`.
158
158
  */
159
159
  function isOnSiteFrame(url, site) {
160
160
  if (url === '' || url === 'about:blank' || url === 'about:srcdoc')
@@ -213,7 +213,7 @@ function assembleTree(nodes, ids, framePath, frameChildren) {
213
213
  // bordered iframe is off by its border width, which is within a tap
214
214
  // target. A frame under a CSS transform is not unwound: its boxes are
215
215
  // where the untransformed frame would put them.
216
- if (embedded !== undefined)
216
+ if (embedded !== undefined && raw.rect !== null)
217
217
  childLists[index].unshift(placeInFrame(embedded, raw.rect));
218
218
  const node = toSemanticNode({ id: ids[index], revision: '' }, raw, childLists[index], framePath);
219
219
  built[index] = node;
@@ -272,7 +272,7 @@ export function toSemanticNode(ref, raw, children = [], framePath = []) {
272
272
  states,
273
273
  ...(raw.level !== null ? { level: raw.level } : {}),
274
274
  attributes: raw.attributes,
275
- rect: raw.rect,
275
+ ...(raw.rect !== null ? { rect: raw.rect } : {}),
276
276
  ...(framePath.length > 0 ? { framePath } : {}),
277
277
  ...(children.length > 0 ? { children } : {}),
278
278
  };
package/dist/observe.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Masked pixel capture for an observation. */
2
- import type { Locator, Page } from 'playwright';
2
+ import type { Locator, Page } from 'playwright-core';
3
3
  import type { ObservationPixels, OperationContext, ViewportSize } from 'e2e/engine';
4
4
  /**
5
5
  * Two mask locators per frame Playwright can reach, covering every secure
@@ -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>;
@@ -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();
@@ -4,7 +4,7 @@
4
4
  * a challenge wherever one is issued. The surface calls these on every
5
5
  * context it creates; nothing here holds state.
6
6
  */
7
- import type { BrowserContext } from 'playwright';
7
+ import type { BrowserContext } from 'playwright-core';
8
8
  import { type ResolveSecretOptions, type Secret } from 'e2e/engine';
9
9
  import type { WebBasicAuth } from './surface.ts';
10
10
  /** The credentials Playwright answers an HTTP authentication challenge with. */
@@ -12,15 +12,31 @@ export interface PlaywrightHttpCredential {
12
12
  readonly username: string;
13
13
  readonly password: string;
14
14
  }
15
+ /** A control character no HTTP field value may carry; a horizontal tab is the one the grammar allows. */
16
+ export declare const FIELD_VALUE_CONTROL: RegExp;
17
+ /**
18
+ * Why the browser could not send this header, or `undefined` when it can: a
19
+ * name outside the token grammar, a value that is not a string, or one
20
+ * carrying a control character (a line break is a header-injection vector).
21
+ * Config and route overrides both check headers here.
22
+ */
23
+ export declare function headerProblem(name: string, value: unknown): string | undefined;
15
24
  /** Header names lowercased, as Playwright reports a request's own headers. */
16
25
  export declare function lowercaseNames(headers: Readonly<Record<string, string>>): Readonly<Record<string, string>>;
26
+ /**
27
+ * The configured headers a request to `url` carries: all of them for the
28
+ * app's site, none for any other site or when there is no site policy.
29
+ */
30
+ export declare function siteHeadersFor(url: string | URL, site: string | undefined, headers: Readonly<Record<string, string>> | undefined): Readonly<Record<string, string>> | undefined;
17
31
  /**
18
32
  * Adds the configured headers to every request bound for the app's site,
19
33
  * via a context route that falls back to the network. A request for any
20
34
  * other site is not routed at all; without a site there is no policy and
21
- * nothing is routed. Nothing awaits a route handler, so a fallback that
22
- * fails (the page closed under the request) is dropped rather than left to
23
- * surface as an unhandled rejection.
35
+ * nothing is routed. Registered before any test route, so it runs last: a
36
+ * test route's `fallback` reaches it, and a `continue`, which skips it,
37
+ * merges the same headers itself (`siteHeadersFor`). Nothing awaits a route
38
+ * handler, so a fallback that fails (the page closed under the request) is
39
+ * dropped rather than left to surface as an unhandled rejection.
24
40
  */
25
41
  export declare function installSiteHeaders(context: BrowserContext, site: string | undefined, headers: Readonly<Record<string, string>> | undefined): Promise<void>;
26
42
  /**
@@ -5,22 +5,53 @@
5
5
  * context it creates; nothing here holds state.
6
6
  */
7
7
  import { sameSite } from 'e2e/engine';
8
+ /** An HTTP header field name: one or more `token` characters (RFC 9110). */
9
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
10
+ /** A control character no HTTP field value may carry; a horizontal tab is the one the grammar allows. */
11
+ // oxlint-disable-next-line no-control-regex -- the control characters are the point
12
+ export const FIELD_VALUE_CONTROL = /[\u0000-\u0008\u000A-\u001F\u007F]/;
13
+ /**
14
+ * Why the browser could not send this header, or `undefined` when it can: a
15
+ * name outside the token grammar, a value that is not a string, or one
16
+ * carrying a control character (a line break is a header-injection vector).
17
+ * Config and route overrides both check headers here.
18
+ */
19
+ export function headerProblem(name, value) {
20
+ if (!HEADER_NAME.test(name))
21
+ return `has an invalid header name: "${name}"`;
22
+ if (typeof value !== 'string')
23
+ return `header "${name}" must be a string, got ${typeof value}`;
24
+ if (FIELD_VALUE_CONTROL.test(value))
25
+ return `header "${name}" must not contain a control character`;
26
+ return undefined;
27
+ }
8
28
  /** Header names lowercased, as Playwright reports a request's own headers. */
9
29
  export function lowercaseNames(headers) {
10
30
  return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
11
31
  }
32
+ /**
33
+ * The configured headers a request to `url` carries: all of them for the
34
+ * app's site, none for any other site or when there is no site policy.
35
+ */
36
+ export function siteHeadersFor(url, site, headers) {
37
+ if (headers === undefined || site === undefined)
38
+ return undefined;
39
+ return sameSite(url, site) ? headers : undefined;
40
+ }
12
41
  /**
13
42
  * Adds the configured headers to every request bound for the app's site,
14
43
  * via a context route that falls back to the network. A request for any
15
44
  * other site is not routed at all; without a site there is no policy and
16
- * nothing is routed. Nothing awaits a route handler, so a fallback that
17
- * fails (the page closed under the request) is dropped rather than left to
18
- * surface as an unhandled rejection.
45
+ * nothing is routed. Registered before any test route, so it runs last: a
46
+ * test route's `fallback` reaches it, and a `continue`, which skips it,
47
+ * merges the same headers itself (`siteHeadersFor`). Nothing awaits a route
48
+ * handler, so a fallback that fails (the page closed under the request) is
49
+ * dropped rather than left to surface as an unhandled rejection.
19
50
  */
20
51
  export async function installSiteHeaders(context, site, headers) {
21
52
  if (headers === undefined || site === undefined)
22
53
  return;
23
- await context.route((url) => sameSite(url, site), async (route) => {
54
+ await context.route((url) => siteHeadersFor(url, site, headers) !== undefined, async (route) => {
24
55
  await route.fallback({ headers: { ...route.request().headers(), ...headers } }).catch(() => undefined);
25
56
  });
26
57
  }
@@ -4,7 +4,7 @@
4
4
  * ended by `stopProviderRecording` into the attempt's one segment, a file in
5
5
  * its `video` directory or a link.
6
6
  */
7
- import type { Page } from 'playwright';
7
+ import type { Page } from 'playwright-core';
8
8
  import { type VideoSegment } from 'e2e/engine';
9
9
  import type { LeaseRecording } from './provider.ts';
10
10
  import type { AttemptVideo } from './video.ts';
@@ -48,12 +48,13 @@ export interface RawNodeData {
48
48
  attributes: Record<string, string>;
49
49
  /** Value of the project's test-id attribute, when the element carries one. */
50
50
  testId: string | null;
51
+ /** The element's box in its document's viewport; null when it lays out no box (`display: none`). */
51
52
  rect: {
52
53
  x: number;
53
54
  y: number;
54
55
  width: number;
55
56
  height: number;
56
- };
57
+ } | null;
57
58
  }
58
59
  /** One observed node plus its position in the flattened depth-first tree. */
59
60
  export interface RawObservedNode extends RawNodeData {
@@ -81,6 +82,10 @@ interface RawObservation {
81
82
  }
82
83
  export type SemanticMode = {
83
84
  kind: 'node';
85
+ }
86
+ /** The element's `states.hidden` alone, for the `visible` narrowing a selector engine applies. */
87
+ | {
88
+ kind: 'hidden';
84
89
  } | {
85
90
  kind: 'tree';
86
91
  maxNodes: number;
@@ -98,7 +103,9 @@ export type SemanticMode = {
98
103
  /** Result of one read, selected by the mode discriminant. */
99
104
  export type SemanticResult<Mode extends SemanticMode> = Mode extends {
100
105
  kind: 'node';
101
- } ? RawNodeData : RawObservation;
106
+ } ? RawNodeData : Mode extends {
107
+ kind: 'hidden';
108
+ } ? boolean : RawObservation;
102
109
  /** Options for one document's tree walk. */
103
110
  interface TreeReadOptions {
104
111
  readonly testIdAttribute: string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Argument validation for a route handler's decision. Every key is checked
3
+ * before the decision is taken: an option this engine does not implement
4
+ * (Playwright's `abort(errorCode)`, `fulfill({ response })`, a misspelled
5
+ * key) is `INVALID_ARGUMENT` rather than a request answered as if the option
6
+ * were absent. A continue URL goes through the harness's navigation rule,
7
+ * the same `resolveUrl` `goto` uses.
8
+ */
9
+ import { type JsonValue } from 'e2e/engine';
10
+ /** What `route.fulfill` hands Playwright once the response is validated. */
11
+ export interface FulfillDecision {
12
+ readonly status: number;
13
+ readonly headers: Record<string, string>;
14
+ readonly contentType?: string;
15
+ readonly json?: JsonValue;
16
+ readonly body?: string;
17
+ /** Absolute; Playwright reads it and derives `Content-Type` from its extension when none is given. */
18
+ readonly path?: string;
19
+ }
20
+ /** What `route.continue` hands Playwright once the overrides are validated; `headers` before the site's are merged in. */
21
+ export interface ContinueDecision {
22
+ readonly url?: string;
23
+ readonly method?: string;
24
+ readonly headers?: Record<string, string>;
25
+ readonly postData?: string;
26
+ }
27
+ /**
28
+ * Validates `route.fulfill(response)`, synchronously so the decision is
29
+ * taken the moment `fulfill` is called. `path` resolves with `resolvePath`
30
+ * (the project root, never `process.cwd()`) and must name a file now, so a
31
+ * missing fixture fails the step with its own message, and `json` follows
32
+ * the JSON-value rules `evaluate` does. A `content-type` header becomes
33
+ * `contentType` when none is given, since Playwright overwrites the header
34
+ * with the type it derives for `json` and `path`.
35
+ */
36
+ export declare function parseFulfill(response: unknown, resolvePath: (file: string) => string): FulfillDecision;
37
+ /**
38
+ * Validates `route.continue(overrides)`. A `url` resolves against the base
39
+ * URL through `resolveUrl`, which denies what navigation denies, and keeps
40
+ * the request's scheme, as the browser requires of a rewritten request.
41
+ */
42
+ export declare function parseContinue(overrides: unknown, requestUrl: string, resolveUrl: (url: string) => string): ContinueDecision;
43
+ /** Rejects any argument to a decision that takes none (`abort`, `fallback`). */
44
+ export declare function requireNoArguments(api: string, args: readonly unknown[]): void;