@e2e-dev/web 0.15.5-nightly-20261104030231 → 0.15.5

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.
@@ -1,18 +1,25 @@
1
- type EvaluationResult = {
2
- ok: true;
3
- value: unknown;
4
- } | {
5
- ok: false;
6
- message: string;
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 });";
1
+ /** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
2
+ import type { JsonValue } from 'e2e';
3
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page; it takes `defineProperty` before that code runs. */
4
+ export declare const KEEP_NAMES_HELPER = "const __name = ((define) => (target, value) => define(target, 'name', { value, configurable: true }))(({}).constructor.defineProperty);";
10
5
  /** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
11
6
  export declare function pageFunctionSource(fn: (...args: never[]) => unknown): string;
7
+ /**
8
+ * Builds, in the page, the encoder of an evaluation result into JSON text.
9
+ * Called before the caller's code runs, it takes the intrinsics it needs from
10
+ * literals, so code that deletes or replaces `Array`, `Object`, or `JSON`
11
+ * cannot reach it. Only JSON passes: `null`, booleans, finite numbers,
12
+ * strings, arrays, and plain objects. Anything else throws `{ invalid }` with
13
+ * the reason `validateJsonValue` gives. Embedded into the page as source, so
14
+ * it captures nothing from this module.
15
+ */
16
+ export declare function pageValueEncoder(): (root: unknown) => string | undefined;
12
17
  /**
13
18
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
14
19
  * The source is one expression: a function it evaluates to is called with the argument, any
15
- * other value is the result.
20
+ * other value is the result. The boundary resolves to JSON text, a primitive the caller takes
21
+ * back without Playwright serializing it in the page, so a page that deleted or replaced a
22
+ * global the serializer needs still returns its result; `decodeEvaluation` reads it.
16
23
  *
17
24
  * A function serialized with `toString()` carries whatever the loader compiled
18
25
  * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
@@ -20,5 +27,6 @@ export declare function pageFunctionSource(fn: (...args: never[]) => unknown): s
20
27
  * helper; the page has no such helper, so the same one is declared next to
21
28
  * the inlined source for as long as this engine supports those runners.
22
29
  */
23
- export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
24
- export {};
30
+ export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<string>;
31
+ /** The result of the boundary `compileEvaluation` compiled, from the JSON text it resolved to; throws what the page threw. */
32
+ export declare function decodeEvaluation(text: string): JsonValue | undefined;
@@ -1,8 +1,7 @@
1
- /** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
2
1
  import { TestError } from 'e2e/engine';
3
2
  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 });";
3
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page; it takes `defineProperty` before that code runs. */
4
+ export const KEEP_NAMES_HELPER = "const __name = ((define) => (target, value) => define(target, 'name', { value, configurable: true }))(({}).constructor.defineProperty);";
6
5
  /** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
7
6
  export function pageFunctionSource(fn) {
8
7
  const source = fn.toString().trim();
@@ -34,10 +33,82 @@ export function pageFunctionSource(fn) {
34
33
  }
35
34
  }
36
35
  }
36
+ /**
37
+ * Builds, in the page, the encoder of an evaluation result into JSON text.
38
+ * Called before the caller's code runs, it takes the intrinsics it needs from
39
+ * literals, so code that deletes or replaces `Array`, `Object`, or `JSON`
40
+ * cannot reach it. Only JSON passes: `null`, booleans, finite numbers,
41
+ * strings, arrays, and plain objects. Anything else throws `{ invalid }` with
42
+ * the reason `validateJsonValue` gives. Embedded into the page as source, so
43
+ * it captures nothing from this module.
44
+ */
45
+ export function pageValueEncoder() {
46
+ const { getPrototypeOf, keys } = ({}).constructor;
47
+ const objectPrototype = getPrototypeOf({});
48
+ const { isArray } = [].constructor;
49
+ const charCodeAt = ''.charCodeAt;
50
+ const hex = '0123456789abcdef';
51
+ const invalid = (reason) => ({ invalid: reason });
52
+ const quote = (text) => {
53
+ let out = '"';
54
+ for (let i = 0; i < text.length; i++) {
55
+ const code = charCodeAt.call(text, i);
56
+ const escape = code === 34 || code === 92 || code < 32 || code === 0x2028 || code === 0x2029 || (code >= 0xd800 && code <= 0xdfff);
57
+ out += escape ? `\\u${hex[code >> 12]}${hex[(code >> 8) & 15]}${hex[(code >> 4) & 15]}${hex[code & 15]}` : text[i];
58
+ }
59
+ return `${out}"`;
60
+ };
61
+ const ancestors = [];
62
+ const encode = (value) => {
63
+ if (value === null)
64
+ return 'null';
65
+ switch (typeof value) {
66
+ case 'string':
67
+ return quote(value);
68
+ case 'boolean':
69
+ return value ? 'true' : 'false';
70
+ case 'number':
71
+ if (value !== value || value === Infinity || value === -Infinity)
72
+ throw invalid('contains a non-finite number');
73
+ return `${value}`;
74
+ case 'object':
75
+ break;
76
+ default:
77
+ throw invalid(`must be JSON-safe, found ${typeof value}`);
78
+ }
79
+ const array = isArray(value);
80
+ const prototype = getPrototypeOf(value);
81
+ if (!array && prototype !== objectPrototype && prototype !== null)
82
+ throw invalid('must be JSON-safe');
83
+ for (let i = 0; i < ancestors.length; i++) {
84
+ if (ancestors[i] === value)
85
+ throw invalid('contains a cycle');
86
+ }
87
+ ancestors[ancestors.length] = value;
88
+ let text = '';
89
+ if (array) {
90
+ for (let i = 0; i < value.length; i++)
91
+ text += `${i === 0 ? '' : ','}${encode(value[i])}`;
92
+ text = `[${text}]`;
93
+ }
94
+ else {
95
+ const record = value;
96
+ const names = keys(record);
97
+ for (let i = 0; i < names.length; i++)
98
+ text += `${i === 0 ? '' : ','}${quote(names[i])}:${encode(record[names[i]])}`;
99
+ text = `{${text}}`;
100
+ }
101
+ ancestors.length -= 1;
102
+ return text;
103
+ };
104
+ return (root) => (root === undefined ? undefined : encode(root));
105
+ }
37
106
  /**
38
107
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
39
108
  * The source is one expression: a function it evaluates to is called with the argument, any
40
- * other value is the result.
109
+ * other value is the result. The boundary resolves to JSON text, a primitive the caller takes
110
+ * back without Playwright serializing it in the page, so a page that deleted or replaced a
111
+ * global the serializer needs still returns its result; `decodeEvaluation` reads it.
41
112
  *
42
113
  * A function serialized with `toString()` carries whatever the loader compiled
43
114
  * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
@@ -48,23 +119,49 @@ export function pageFunctionSource(fn) {
48
119
  export function compileEvaluation(source, hasArgument) {
49
120
  try {
50
121
  return new Function('arg', `
51
- return (async () => {
52
- ${KEEP_NAMES_HELPER}
122
+ ${KEEP_NAMES_HELPER}
123
+ return (async (encode, run) => {
124
+ const report = (ok, key, text) => \`{"ok":\${ok}\${text === undefined ? '' : \`,"\${key}":\${text}\`}}\`;
125
+ let value;
53
126
  try {
54
- return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
127
+ value = await run();
55
128
  } catch (cause) {
56
129
  let message;
57
130
  try {
58
- message = typeof cause?.message === 'string' ? cause.message : String(cause);
131
+ message = typeof cause?.message === 'string' ? cause.message : \`\${cause}\`;
59
132
  } catch {
60
133
  message = 'Page evaluation threw an unprintable value';
61
134
  }
62
- return { ok: false, message };
135
+ return report(false, 'message', encode(message));
63
136
  }
64
- })();
137
+ try {
138
+ return report(true, 'value', encode(value));
139
+ } catch (cause) {
140
+ if (cause !== null && typeof cause === 'object' && typeof cause.invalid === 'string') {
141
+ return report(false, 'invalid', encode(cause.invalid));
142
+ }
143
+ return '{"ok":false,"message":"evaluate result cannot be read"}';
144
+ }
145
+ })((${pageValueEncoder.toString()})(), async () => ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)));
65
146
  `);
66
147
  }
67
148
  catch (cause) {
68
149
  throw new TestError('EVALUATE_FAILED', message(cause), { cause });
69
150
  }
70
151
  }
152
+ /** The result of the boundary `compileEvaluation` compiled, from the JSON text it resolved to; throws what the page threw. */
153
+ export function decodeEvaluation(text) {
154
+ let report;
155
+ try {
156
+ // oxlint-disable-next-line e2e/no-unchecked-json -- the text is what compileEvaluation's own boundary wrote
157
+ report = JSON.parse(text);
158
+ }
159
+ catch (cause) {
160
+ throw new TestError('EVALUATE_FAILED', 'page evaluation returned an unreadable result', { cause });
161
+ }
162
+ if (report.ok)
163
+ return report.value;
164
+ if ('invalid' in report)
165
+ throw new TestError('INVALID_ARGUMENT', `evaluate result ${report.invalid}`);
166
+ throw new TestError('EVALUATE_FAILED', report.message);
167
+ }
@@ -0,0 +1,20 @@
1
+ /** Which frames hold a document script can read. */
2
+ import type { ElementHandle, Frame } from 'playwright-core';
3
+ /**
4
+ * True when Playwright can evaluate in the frame without waiting on a
5
+ * navigation.
6
+ *
7
+ * A child frame that has not committed a navigation (its URL is empty, as
8
+ * while its first request is held) shows its initial empty document.
9
+ * Chromium creates no script context for that document until script in its
10
+ * parent touches it, and Playwright waits for the navigation to commit
11
+ * instead. Reading `contentDocument` from the parent is that touch, so the
12
+ * frame is readable from then on. A parent that cannot read it (a sandboxed
13
+ * frame) means no script could have written into it either: it shows
14
+ * nothing. When the check itself fails, the frame counts as readable, so the
15
+ * caller's own read of it decides. `owner` is the frame's element when the
16
+ * caller already holds it.
17
+ */
18
+ export declare function hasReadableDocument(frame: Frame, owner?: ElementHandle): Promise<boolean>;
19
+ /** The page's frames whose documents script can read; see `hasReadableDocument`. */
20
+ export declare function readableFrames(frames: readonly Frame[]): Promise<Frame[]>;
@@ -0,0 +1,40 @@
1
+ /** Which frames hold a document script can read. */
2
+ /**
3
+ * True when Playwright can evaluate in the frame without waiting on a
4
+ * navigation.
5
+ *
6
+ * A child frame that has not committed a navigation (its URL is empty, as
7
+ * while its first request is held) shows its initial empty document.
8
+ * Chromium creates no script context for that document until script in its
9
+ * parent touches it, and Playwright waits for the navigation to commit
10
+ * instead. Reading `contentDocument` from the parent is that touch, so the
11
+ * frame is readable from then on. A parent that cannot read it (a sandboxed
12
+ * frame) means no script could have written into it either: it shows
13
+ * nothing. When the check itself fails, the frame counts as readable, so the
14
+ * caller's own read of it decides. `owner` is the frame's element when the
15
+ * caller already holds it.
16
+ */
17
+ export async function hasReadableDocument(frame, owner) {
18
+ if (frame.url() !== '' || frame.parentFrame() === null)
19
+ return true;
20
+ if (owner !== undefined)
21
+ return parentReadsDocument(owner);
22
+ const fetched = await frame.frameElement().catch(() => null);
23
+ if (fetched === null)
24
+ return true;
25
+ try {
26
+ return await parentReadsDocument(fetched);
27
+ }
28
+ finally {
29
+ await fetched.dispose().catch(() => undefined);
30
+ }
31
+ }
32
+ /** The page's frames whose documents script can read; see `hasReadableDocument`. */
33
+ export async function readableFrames(frames) {
34
+ const readable = await Promise.all(frames.map((frame) => hasReadableDocument(frame)));
35
+ return frames.filter((_, index) => readable[index]);
36
+ }
37
+ /** Reads the frame's document from its parent, which gives it a script context; true when that read fails. */
38
+ function parentReadsDocument(owner) {
39
+ return owner.evaluate((element) => 'contentDocument' in element && element.contentDocument !== null).catch(() => true);
40
+ }
@@ -1460,6 +1460,8 @@ export const readSemanticsFunction = (element, options) => {
1460
1460
  // no rect, as Playwright's `boundingBox()` answers null; a box with no
1461
1461
  // size, or one `visibility: hidden` keeps, is still a rect.
1462
1462
  const rect = el.getClientRects().length === 0 ? null : el.getBoundingClientRect();
1463
+ const borderLeft = Number.parseFloat(style?.borderLeftWidth ?? '') || 0;
1464
+ const borderTop = Number.parseFloat(style?.borderTopWidth ?? '') || 0;
1463
1465
  const role = isDocumentRoot ? 'document' : roleOf(el, tag);
1464
1466
  let level = null;
1465
1467
  if (role === 'heading') {
@@ -1494,6 +1496,7 @@ export const readSemanticsFunction = (element, options) => {
1494
1496
  attributes,
1495
1497
  testId: el.getAttribute(options.testIdAttribute),
1496
1498
  rect: rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1499
+ origin: rect === null || (borderLeft === 0 && borderTop === 0) ? null : { x: rect.x + borderLeft, y: rect.y + borderTop },
1497
1500
  };
1498
1501
  };
1499
1502
  // The conditional return type resolves per call site; inside the body the
package/dist/install.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function runPlaywrightCli(args: readonly string[]): Promise<numbe
13
13
  * disk, a `warn` when the first run will download it, or none when `env`
14
14
  * points at another browser cache than this process loaded.
15
15
  */
16
- export declare function browserCheck(name: BrowserName, env: NodeJS.ProcessEnv, headed: boolean, isInstalled?: (name: BrowserName) => boolean | undefined): EngineDoctorCheck[];
16
+ export declare function browserCheck(name: BrowserName, env: NodeJS.ProcessEnv, headed: boolean, isInstalled?: (name: BrowserName) => boolean | undefined | Promise<boolean | undefined>): Promise<EngineDoctorCheck[]>;
17
17
  /** What an installer receives besides the browser names. */
18
18
  export interface InstallContext {
19
19
  /** Receives every line the installer prints: the download URL, progress, warnings. */
@@ -36,7 +36,7 @@ export interface EnsureBrowsersOptions {
36
36
  * Injected detection for tests; defaults to an executable existence check.
37
37
  * Undefined means "cannot tell here": the installer runs and decides.
38
38
  */
39
- readonly isInstalled?: (name: BrowserName) => boolean | undefined;
39
+ readonly isInstalled?: (name: BrowserName) => boolean | undefined | Promise<boolean | undefined>;
40
40
  /** Injected installer for tests; defaults to spawning the Playwright CLI. */
41
41
  readonly install?: (names: readonly BrowserName[], context: InstallContext) => Promise<void>;
42
42
  /** Aborts a download in progress; the run's interrupt owns it. */
package/dist/install.js CHANGED
@@ -18,11 +18,11 @@ function headlessShellInstalled(browsersPath, revision) {
18
18
  * Whether the build the run launches (headless shell for headless chromium, else the full build)
19
19
  * is on disk, or undefined when `env` points at another cache than this process loaded.
20
20
  */
21
- function isBrowserInstalled(name, env, headed) {
21
+ async function isBrowserInstalled(name, env, headed) {
22
22
  if (env['PLAYWRIGHT_BROWSERS_PATH'] !== process.env['PLAYWRIGHT_BROWSERS_PATH'])
23
23
  return undefined;
24
24
  try {
25
- const executable = browserType(name).executablePath();
25
+ const executable = (await browserType(name)).executablePath();
26
26
  if (headed || name !== 'chromium')
27
27
  return existsSync(executable);
28
28
  return headlessShellAt(executable);
@@ -79,8 +79,8 @@ export function runPlaywrightCli(args) {
79
79
  * disk, a `warn` when the first run will download it, or none when `env`
80
80
  * points at another browser cache than this process loaded.
81
81
  */
82
- export function browserCheck(name, env, headed, isInstalled = (browser) => isBrowserInstalled(browser, env, headed)) {
83
- const installed = isInstalled(name);
82
+ export async function browserCheck(name, env, headed, isInstalled = (browser) => isBrowserInstalled(browser, env, headed)) {
83
+ const installed = await isInstalled(name);
84
84
  if (installed === undefined)
85
85
  return [];
86
86
  if (installed)
@@ -115,7 +115,7 @@ export async function ensureBrowsersInstalled(names, options = {}) {
115
115
  const env = options.env ?? process.env;
116
116
  const headed = options.headed ?? false;
117
117
  const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env, headed));
118
- const verdicts = new Map([...new Set(names)].map((name) => [name, isInstalled(name)]));
118
+ const verdicts = new Map(await Promise.all([...new Set(names)].map(async (name) => [name, await isInstalled(name)])));
119
119
  const missing = [...verdicts.keys()].filter((name) => verdicts.get(name) !== true);
120
120
  if (missing.length === 0)
121
121
  return;
@@ -17,6 +17,13 @@ export type PostStep = {
17
17
  readonly kind: 'filter';
18
18
  readonly options: PwFilterOptions;
19
19
  };
20
+ /** What a projection reads besides the expression. */
21
+ export interface ProjectionOptions {
22
+ /** The attribute a `testId` query reads. */
23
+ readonly testIdAttribute: string;
24
+ /** True when a frame along the expression's frame chain is hidden, so every node in it is. */
25
+ readonly frameHidden: boolean;
26
+ }
20
27
  export interface ProjectedLocator {
21
28
  /**
22
29
  * The Playwright locator to resolve. For a display-value query this is the
@@ -61,8 +68,8 @@ export interface ProjectedLocator {
61
68
  */
62
69
  readonly visible: boolean;
63
70
  }
64
- /** Projects an expression onto the page; `testIdAttribute` is what a `testId` query reads. */
65
- export declare function projectExpression(page: Page, expression: LocatorExpression, testIdAttribute: string): ProjectedLocator;
71
+ /** Projects an expression onto the page. */
72
+ export declare function projectExpression(page: Page, expression: LocatorExpression, options: ProjectionOptions): ProjectedLocator;
66
73
  /**
67
74
  * Applies post steps, innermost first, to an ordered list of matches. `nth`
68
75
  * past the end selects nothing, matching Playwright's `nth` on a locator with
package/dist/locators.js CHANGED
@@ -152,11 +152,19 @@ function queryToPw(scope, query, testIdAttribute) {
152
152
  * never selects or retains a node that state calls hidden, and the surface's
153
153
  * hold of a terminal query to the batch-read state agrees with it.
154
154
  */
155
- function visibleQueryToPw(scope, query, testIdAttribute) {
156
- return narrowedToVisible(queryToPw(scope, query, testIdAttribute), query);
155
+ function visibleQueryToPw(scope, query, options) {
156
+ return narrowedToVisible(queryToPw(scope, query, options.testIdAttribute), query, options.frameHidden);
157
157
  }
158
- /** The visibility narrowing above, on a locator already built for `query`; identity unless the query is `visible`. */
159
- function narrowedToVisible(located, query) {
158
+ /**
159
+ * Matches no element. Inside a hidden frame every node reads as hidden, which
160
+ * the frame's own document cannot see, so a query that never matches a
161
+ * hidden node, a `visible` or a role query, narrows to it there.
162
+ */
163
+ const NOTHING_SELECTOR = 'css=:not(*)';
164
+ /** The visibility narrowing above, on a locator already built for `query`; identity unless the query keeps shown nodes alone. */
165
+ function narrowedToVisible(located, query, frameHidden) {
166
+ if (frameHidden && (query.visible === true || query.kind === 'role'))
167
+ return located.locator(NOTHING_SELECTOR);
160
168
  if (query.visible !== true)
161
169
  return located;
162
170
  return located.locator(SHOWN_SELECTOR);
@@ -192,20 +200,20 @@ const DISPLAY_VALUE_COMPOSITION_MESSAGE = 'displayValue queries cannot scope chi
192
200
  * unsupported: a display-value query as the scope of a child query, and as a
193
201
  * `has` filter.
194
202
  */
195
- function project(scope, expression, testIdAttribute) {
203
+ function project(scope, expression, options) {
196
204
  switch (expression.kind) {
197
205
  case 'union': {
198
- const left = requireComposable(project(scope, expression.left, testIdAttribute));
199
- const right = requireComposable(project(scope, expression.right, testIdAttribute));
206
+ const left = requireComposable(project(scope, expression.left, options));
207
+ const right = requireComposable(project(scope, expression.right, options));
200
208
  return plain(left.or(right));
201
209
  }
202
210
  case 'query': {
203
211
  const inner = expression.scope === undefined
204
212
  ? scope
205
- : requireComposable(project(scope, expression.scope, testIdAttribute));
213
+ : requireComposable(project(scope, expression.scope, options));
206
214
  const { query } = expression;
207
215
  const exactLabel = query.kind === 'label' && query.value.kind === 'string' && query.value.exact ? query.value : null;
208
- const locator = visibleQueryToPw(inner, query, testIdAttribute);
216
+ const locator = visibleQueryToPw(inner, query, options);
209
217
  return {
210
218
  locator,
211
219
  displayValue: query.kind === 'displayValue' ? query.value : null,
@@ -216,30 +224,30 @@ function project(scope, expression, testIdAttribute) {
216
224
  };
217
225
  }
218
226
  case 'filter': {
219
- const source = project(scope, expression.source, testIdAttribute);
220
- const options = {};
227
+ const source = project(scope, expression.source, options);
228
+ const filter = {};
221
229
  if (expression.hasText !== undefined)
222
- options.hasText = patternToPw(expression.hasText);
230
+ filter.hasText = patternToPw(expression.hasText);
223
231
  if (expression.hasNotText !== undefined)
224
- options.hasNotText = patternToPw(expression.hasNotText);
232
+ filter.hasNotText = patternToPw(expression.hasNotText);
225
233
  if (expression.has !== undefined) {
226
- options.has = requireComposable(project(scope, expression.has, testIdAttribute));
234
+ filter.has = requireComposable(project(scope, expression.has, options));
227
235
  }
228
236
  if (expression.hasNot !== undefined) {
229
- options.hasNot = requireComposable(project(scope, expression.hasNot, testIdAttribute));
237
+ filter.hasNot = requireComposable(project(scope, expression.hasNot, options));
230
238
  }
231
239
  if (source.steps.length > 0) {
232
- return { ...source, steps: [...source.steps, { kind: 'filter', options }] };
240
+ return { ...source, steps: [...source.steps, { kind: 'filter', options: filter }] };
233
241
  }
234
242
  return {
235
243
  ...source,
236
- locator: source.locator.filter(options),
237
- composable: source.composable === null ? null : source.composable.filter(options),
244
+ locator: source.locator.filter(filter),
245
+ composable: source.composable === null ? null : source.composable.filter(filter),
238
246
  steps: [],
239
247
  };
240
248
  }
241
249
  case 'index': {
242
- const source = project(scope, expression.source, testIdAttribute);
250
+ const source = project(scope, expression.source, options);
243
251
  if (source.displayValue !== null || source.name !== null) {
244
252
  return {
245
253
  ...source,
@@ -252,12 +260,12 @@ function project(scope, expression, testIdAttribute) {
252
260
  case 'selector':
253
261
  return plain(scope.locator(expression.selector));
254
262
  case 'frame':
255
- return project(scope.frameLocator(expression.selector), expression.source, testIdAttribute);
263
+ return project(scope.frameLocator(expression.selector), expression.source, options);
256
264
  }
257
265
  }
258
- /** Projects an expression onto the page; `testIdAttribute` is what a `testId` query reads. */
259
- export function projectExpression(page, expression, testIdAttribute) {
260
- return project(page, expression, testIdAttribute);
266
+ /** Projects an expression onto the page. */
267
+ export function projectExpression(page, expression, options) {
268
+ return project(page, expression, options);
261
269
  }
262
270
  /**
263
271
  * Returns the locator of a projection that must participate in Playwright's
@@ -37,11 +37,11 @@ export async function captureObservation(page, refs, settings, operation, option
37
37
  captureDocument({
38
38
  ...settings,
39
39
  reserveIds: (count) => refs.reserveIds(count),
40
- commit: (id, element) => {
40
+ commit: (id, target) => {
41
41
  if (accepting && !operation.signal.aborted)
42
- generation.set(id, { kind: 'element', element });
42
+ generation.set(id, target);
43
43
  else
44
- void element.dispose().catch(() => undefined);
44
+ target.list.dispose();
45
45
  },
46
46
  }, page, {
47
47
  framePath: [], budget: MAX_OBSERVED_NODES,
@@ -2,9 +2,10 @@
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-core';
6
- import { type NodeRef, type SemanticNode } from 'e2e/engine';
5
+ import type { Frame } from 'playwright-core';
6
+ import { type NodeRef, type SemanticNode, type ViewportPoint } from 'e2e/engine';
7
7
  import { type RawNodeData } from './read-node.ts';
8
+ import type { ListedTarget } from './refs.ts';
8
9
  /**
9
10
  * The id of every observation's root: the main document's element under one
10
11
  * id the engine mints, stable across observations and navigations, so the
@@ -21,8 +22,8 @@ export interface CaptureDeps {
21
22
  readonly site: string | undefined;
22
23
  /** Reserves a disjoint range before the reader can stamp any element, including on timeout. */
23
24
  reserveIds(count: number): number;
24
- /** Publishes one element handle under its id in the generation being built. */
25
- commit(id: string, element: ElementHandle<Element>): void;
25
+ /** Publishes one observed node under its id in the generation being built. */
26
+ commit(id: string, target: ListedTarget): void;
26
27
  }
27
28
  export interface CaptureOptions {
28
29
  /** Enclosing frame selectors, outermost first; empty for the main document. */
@@ -69,10 +70,13 @@ export declare function frameContentInset(element: Element): Rect;
69
70
  /** A frame element's content box, from its border box and `frameContentInset`. */
70
71
  export declare function contentBox(border: Rect, inset: Rect): Rect;
71
72
  /**
72
- * A box a child document measured against its own viewport, in the top-level
73
- * viewport's space: shifted by the frame's box and clipped to it; undefined
74
- * when nothing of it is inside the frame.
73
+ * A node box a child document measured against its own viewport, in the
74
+ * top-level viewport's space: `rect` shifted by the frame's box and clipped
75
+ * to it, empty when nothing of it is inside the frame. `origin` is shifted
76
+ * but never clipped, and a clip that moves the box's corner keeps the
77
+ * unclipped corner as `origin`, so an offset into the node lands where it
78
+ * would at the top level.
75
79
  */
76
- export declare function placeRectInFrame(rect: Rect, frame: Rect): Rect | undefined;
80
+ export declare function placeBoxInFrame(rect: Rect, origin: ViewportPoint | undefined, frame: Rect): Pick<SemanticNode, 'rect' | 'origin'>;
77
81
  export declare function toSemanticNode(ref: NodeRef, raw: RawNodeData, children?: readonly SemanticNode[], framePath?: readonly string[]): SemanticNode;
78
82
  export {};