@ddwang/magnitude-core 0.3.1-ddwang.13 → 0.3.1-ddwang.14

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.
@@ -0,0 +1,3 @@
1
+ import type { Frame } from 'playwright';
2
+ /** Applies to one document; the transformer calls it for each frame load and navigation. */
3
+ export declare function renderSelectPickersInFrame(frame: Frame): Promise<void>;
@@ -0,0 +1,20 @@
1
+ // Native select popups render outside the page, so screenshots never show their options.
2
+ // Chrome 135+ draws a base-select picker in the page's top layer, where screenshots include it.
3
+ // Multi-selects and list boxes already render in the page.
4
+ // Dropdowns are single-choice selects without a list-box size. !important outranks author and inline
5
+ // styles such as appearance: none, which would otherwise keep the native, unscreenshotted popup.
6
+ const DROPDOWNS = ['select:not([multiple]):not([size])', 'select:not([multiple])[size="0"]', 'select:not([multiple])[size="1"]'];
7
+ const CSS = `${DROPDOWNS.join(', ')}, ${DROPDOWNS.map(selector => `${selector}::picker(select)`).join(', ')} { appearance: base-select !important; }`;
8
+ // A constructed stylesheet adds no DOM node, so the page's own markup is unchanged.
9
+ function install(css) {
10
+ const sheets = document.adoptedStyleSheets;
11
+ if (sheets.some(sheet => sheet.cssRules[0]?.cssText.includes('base-select')))
12
+ return;
13
+ const sheet = new CSSStyleSheet();
14
+ sheet.replaceSync(css);
15
+ document.adoptedStyleSheets = [...sheets, sheet];
16
+ }
17
+ /** Applies to one document; the transformer calls it for each frame load and navigation. */
18
+ export async function renderSelectPickersInFrame(frame) {
19
+ await frame.evaluate(install, CSS).catch(() => { }); // A detached or navigating frame has no document to style.
20
+ }
@@ -5,7 +5,31 @@ export declare const GROUNDED_CLICK_REJECTED: Readonly<{
5
5
  reason: "target_unavailable";
6
6
  instruction: "No click was submitted. Replan from the new observation; do not reuse the rejected reference.";
7
7
  }>;
8
- /** One disposable snapshot, owned by the operation that observed it. No DOM attributes are injected. */
8
+ export declare const GROUNDED_INPUT_REJECTED: Readonly<{
9
+ changed: false;
10
+ reason: "target_unavailable";
11
+ instruction: "No value was set. Replan from the new observation; do not reuse the rejected reference.";
12
+ }>;
13
+ /** Results that submitted nothing; the executor stops the remaining batch after any of them. */
14
+ export declare const GROUNDED_REJECTIONS: ReadonlySet<unknown>;
15
+ declare const REJECTIONS: {
16
+ target_unavailable: Readonly<{
17
+ changed: false;
18
+ reason: "target_unavailable";
19
+ instruction: "No value was set. Replan from the new observation; do not reuse the rejected reference.";
20
+ }>;
21
+ option_unavailable: Readonly<{
22
+ changed: false;
23
+ reason: "option_unavailable";
24
+ instruction: "No value was set. Choose exactly one enabled option label from the observation.";
25
+ }>;
26
+ invalid_value: Readonly<{
27
+ changed: false;
28
+ reason: "invalid_value";
29
+ instruction: "No value was set. Use the input's ISO format, such as yyyy-mm-dd for date, HH:MM for time, or yyyy-mm for month.";
30
+ }>;
31
+ };
32
+ /** One disposable snapshot per frame, owned by the operation that observed it. No DOM attributes are injected. */
9
33
  export declare class GroundedControls {
10
34
  private snapshot?;
11
35
  clear(): Promise<void>;
@@ -17,9 +41,18 @@ export declare class GroundedControls {
17
41
  label: string;
18
42
  context: string;
19
43
  enabled: boolean;
44
+ value?: string;
45
+ options?: string[];
20
46
  ambiguous: boolean;
21
47
  ref: string;
22
48
  }[];
23
49
  }>;
50
+ private entry;
24
51
  click(harness: WebHarness, ref: string): Promise<boolean>;
52
+ /** Sets a native select or date-like input through the DOM, where its picker can't be seen. */
53
+ setValue(harness: WebHarness, ref: string, value: string, kind: 'select' | 'fill'): Promise<{
54
+ changed: true;
55
+ value: string;
56
+ } | (typeof REJECTIONS)[keyof typeof REJECTIONS]>;
25
57
  }
58
+ export {};
@@ -2,8 +2,19 @@ import { randomUUID } from 'node:crypto';
2
2
  import { checkOperation, currentOperation } from '@/common/operation';
3
3
  export const GROUNDED_CLICK_REJECTED = Object.freeze({ clicked: false, reason: 'target_unavailable',
4
4
  instruction: 'No click was submitted. Replan from the new observation; do not reuse the rejected reference.' });
5
- /** A deliberately small, current-viewport subset; this is not an accessibility tree. */
6
- function captureControls() {
5
+ export const GROUNDED_INPUT_REJECTED = Object.freeze({ changed: false, reason: 'target_unavailable',
6
+ instruction: 'No value was set. Replan from the new observation; do not reuse the rejected reference.' });
7
+ const OPTION_UNAVAILABLE = Object.freeze({ changed: false, reason: 'option_unavailable',
8
+ instruction: 'No value was set. Choose exactly one enabled option label from the observation.' });
9
+ const INVALID_VALUE = Object.freeze({ changed: false, reason: 'invalid_value',
10
+ instruction: 'No value was set. Use the input\'s ISO format, such as yyyy-mm-dd for date, HH:MM for time, or yyyy-mm for month.' });
11
+ /** Results that submitted nothing; the executor stops the remaining batch after any of them. */
12
+ export const GROUNDED_REJECTIONS = new Set([GROUNDED_CLICK_REJECTED, GROUNDED_INPUT_REJECTED, OPTION_UNAVAILABLE, INVALID_VALUE]);
13
+ const REJECTIONS = { target_unavailable: GROUNDED_INPUT_REJECTED, option_unavailable: OPTION_UNAVAILABLE, invalid_value: INVALID_VALUE };
14
+ // Single-choice native form controls whose pickers render outside the page screenshot.
15
+ const FIELDS = 'select:not([multiple]),input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"],input[type="week"]';
16
+ /** A deliberately small, current-viewport subset of one frame; this is not an accessibility tree. */
17
+ function captureControls(fields) {
7
18
  const documentAtCapture = document;
8
19
  const url = location.href;
9
20
  const visibility = { checkOpacity: true, checkVisibilityCSS: true };
@@ -13,8 +24,20 @@ function captureControls() {
13
24
  + '[role~="menuitem"],[role~="menuitemcheckbox"],[role~="menuitemradio"],[role~="option"],'
14
25
  + '[role~="combobox"],[role~="listbox"],[role~="textbox"],[role~="searchbox"],'
15
26
  + '[role~="slider"],[role~="spinbutton"],[role~="scrollbar"],[role~="tab"],[role~="treeitem"]';
27
+ const text = (value) => (value ?? '').replace(/\s+/g, ' ').trim();
28
+ // Options a person could choose: neither the option nor its group is disabled or hidden.
29
+ // Computed styles work while the picker is closed; layout-based visibility checks do not.
30
+ const concealed = (element) => {
31
+ const style = getComputedStyle(element);
32
+ return element.hidden || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
33
+ };
34
+ const eligible = (select) => Array.from(select.options).filter(option => {
35
+ const group = option.parentElement instanceof HTMLOptGroupElement ? option.parentElement : null;
36
+ return !option.disabled && !concealed(option) && !(group && (group.disabled || concealed(group)));
37
+ });
16
38
  function describe(node) {
17
- if (!(node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement)
39
+ const field = node.matches(fields) ? node : null;
40
+ if (!(field || node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement)
18
41
  || !node.isConnected || !node.checkVisibility(visibility) || node.closest('[hidden],[inert],[aria-hidden="true"]'))
19
42
  return null;
20
43
  if (node instanceof HTMLButtonElement && node.form && node.type !== 'button')
@@ -26,70 +49,73 @@ function captureControls() {
26
49
  || box.right > innerWidth || box.bottom > innerHeight)
27
50
  return null;
28
51
  const labelledBy = node.getAttribute('aria-labelledby');
29
- const label = (labelledBy
52
+ const label = text(labelledBy
30
53
  ? labelledBy.split(/\s+/).map(id => document.getElementById(id)?.textContent ?? '').join(' ')
31
- : node.getAttribute('aria-label') || node.innerText || node.getAttribute('title') || '').trim();
54
+ : node.getAttribute('aria-label') || (field
55
+ // A wrapping label also contains the field's own text, such as its option labels.
56
+ ? Array.from(field.labels?.[0]?.childNodes ?? []).filter(child => !(child instanceof Element && child.matches('input,select,textarea,button')))
57
+ .map(child => child.textContent).join(' ').trim() || node.getAttribute('title') || node.getAttribute('name')
58
+ : node.innerText || node.getAttribute('title')));
32
59
  if (!label || label.length > 256)
33
60
  return null;
34
61
  const container = node.closest('tr,[role="row"],li,fieldset,form,dialog,[role="dialog"],section,article');
35
- const context = (container?.matches('tr,[role="row"],li')
62
+ const context = text(container?.matches('tr,[role="row"],li')
36
63
  ? container.innerText
37
- : container?.getAttribute('aria-label') || container?.querySelector(':scope > legend,:scope > h1,:scope > h2,:scope > h3')?.textContent || '').trim();
64
+ : container?.getAttribute('aria-label') || container?.querySelector(':scope > legend,:scope > h1,:scope > h2,:scope > h3')?.textContent);
38
65
  if (context.length > 256)
39
66
  return null;
40
- const enabled = !node.matches(':disabled') && !node.closest('[aria-disabled="true"]');
41
- const role = node.getAttribute('role') || (node instanceof HTMLAnchorElement ? 'link' : 'button');
67
+ const enabled = !node.matches(':disabled') && !node.closest('[aria-disabled="true"]') && !field?.readOnly;
68
+ const role = field instanceof HTMLSelectElement ? 'select' : field ? field.type
69
+ : node.getAttribute('role') || (node instanceof HTMLAnchorElement ? 'link' : 'button');
70
+ const value = field instanceof HTMLSelectElement ? text(field.selectedOptions[0]?.label) : field ? field.value : undefined;
71
+ const options = field instanceof HTMLSelectElement ? eligible(field).slice(0, 40).map(option => text(option.label)) : undefined;
42
72
  // Keep activation attributes and context identity local, not in diagnostics.
43
- const identity = JSON.stringify([label, context, role, enabled,
73
+ const identity = JSON.stringify([label, context, role, enabled, node.getAttribute('name'),
44
74
  node.getAttribute('href'), node instanceof HTMLAnchorElement ? node.href : null,
45
75
  node.getAttribute('target'), node.getAttribute('download'), node.getAttribute('type'),
46
76
  node.getAttribute('popovertarget'), node.getAttribute('popovertargetaction'),
47
77
  node.getAttribute('commandfor'), node.getAttribute('command'),
48
78
  node.getAttribute('aria-expanded'), node.getAttribute('aria-selected'), node.getAttribute('aria-pressed')]);
49
- const form = node instanceof HTMLButtonElement ? node.form : null;
50
- return { label, context, role, enabled, identity, container, form, x: box.x + box.width / 2, y: box.y + box.height / 2 };
79
+ const form = node instanceof HTMLButtonElement || field ? node.form : null;
80
+ return { label, context, role, enabled, value, options, identity, container, form,
81
+ box: { x: box.x, y: box.y, width: box.width, height: box.height } };
51
82
  }
52
83
  const nodes = [];
53
84
  const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT, {
54
- acceptNode: node => node.matches('a[href],button') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
85
+ acceptNode: node => node.matches(`a[href],button,${fields}`) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
55
86
  }); // Does not cross frames or shadow roots; stop collecting at the first match beyond the cap.
56
87
  while (nodes.length <= 512 && walker.nextNode())
57
88
  nodes.push(walker.currentNode);
58
89
  const entries = [];
59
- let truncated = nodes.length > 512;
90
+ const truncated = nodes.length > 512;
60
91
  if (!truncated)
61
92
  for (const node of nodes) {
62
93
  const state = describe(node);
63
94
  if (state)
64
95
  entries.push({ node, state });
65
96
  }
66
- const counts = new Map();
67
- const key = (state) => JSON.stringify([state.role, state.label, state.context]);
68
- for (const { state } of entries)
69
- counts.set(key(state), (counts.get(key(state)) ?? 0) + 1);
70
- let bytes = 0;
71
- const controls = [];
72
- for (const { state } of entries) {
73
- const item = { role: state.role, label: state.label, context: state.context, enabled: state.enabled, ambiguous: counts.get(key(state)) > 1 };
74
- bytes += new TextEncoder().encode(JSON.stringify(item)).length + 100; // Reserve reference/JSON overhead.
75
- if (controls.length === 64 || bytes > 16_000) {
76
- truncated = true;
77
- break;
78
- }
79
- controls.push(item);
97
+ // Re-check a reference against the node it was captured from, without dispatching anything.
98
+ function current(index) {
99
+ const entry = entries[index];
100
+ if (document !== documentAtCapture || location.href !== url || !entry)
101
+ return null;
102
+ const state = describe(entry.node);
103
+ if (!state || !state.enabled || state.container !== entry.state.container
104
+ || state.form !== entry.state.form || state.identity !== entry.state.identity)
105
+ return null;
106
+ return { entry, state };
80
107
  }
81
108
  return {
82
- controls, truncated,
83
- resolve(index) {
84
- const entry = entries[index];
85
- if (document !== documentAtCapture || location.href !== url || !entry || !controls[index]
86
- || controls[index].ambiguous)
87
- return null;
88
- const state = describe(entry.node);
89
- if (!state || !state.enabled || state.container !== entry.state.container
90
- || state.form !== entry.state.form || state.identity !== entry.state.identity)
109
+ truncated,
110
+ controls: entries.map(({ state }) => ({ role: state.role, label: state.label, context: state.context, enabled: state.enabled,
111
+ ...(state.value !== undefined ? { value: state.value } : {}), ...(state.options ? { options: state.options } : {}), box: state.box })),
112
+ point(index) {
113
+ const resolved = current(index);
114
+ if (!resolved)
91
115
  return null;
92
- const hit = document.elementFromPoint(state.x, state.y);
116
+ const { entry, state } = resolved;
117
+ const x = state.box.x + state.box.width / 2, y = state.box.y + state.box.height / 2;
118
+ const hit = document.elementFromPoint(x, y);
93
119
  // Text/icon descendants belong to this control; nested controls do not.
94
120
  if (!hit || hit.closest(interactive) !== entry.node)
95
121
  return null;
@@ -99,44 +125,191 @@ function captureControls() {
99
125
  if (descendant.shadowRoot || descendant.localName.includes('-') || descendant.hasAttribute('is'))
100
126
  return null;
101
127
  }
102
- return { x: state.x, y: state.y };
128
+ return { x, y };
129
+ },
130
+ // Checks a field change without making it: the reference is current, the field receives
131
+ // input at its center, and the value is one the field accepts.
132
+ check(index, kind, value) {
133
+ const at = this.point(index);
134
+ const node = entries[index]?.node;
135
+ if (!at || !node?.matches(fields) || (kind === 'select') !== node instanceof HTMLSelectElement)
136
+ return { ok: false, reason: 'target_unavailable' };
137
+ if (node instanceof HTMLSelectElement) {
138
+ const matches = eligible(node).filter(option => text(option.label) === value);
139
+ return matches.length === 1 ? { ok: true, point: at, option: matches[0].index } : { ok: false, reason: 'option_unavailable' };
140
+ }
141
+ // A detached input of the same type sanitizes an invalid value to empty, without touching the page.
142
+ const probe = document.createElement('input');
143
+ probe.type = node.type;
144
+ probe.value = value;
145
+ return probe.value === value ? { ok: true, point: at, option: -1 } : { ok: false, reason: 'invalid_value' };
146
+ },
147
+ // Rechecks and changes the field in one step, through the native setter, then reports its value.
148
+ apply(index, kind, value) {
149
+ const checked = this.check(index, kind, value);
150
+ if (!checked.ok)
151
+ return checked;
152
+ const node = entries[index].node;
153
+ if (node instanceof HTMLSelectElement) {
154
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex').set.call(node, checked.option);
155
+ }
156
+ else {
157
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(node, value);
158
+ }
159
+ node.dispatchEvent(new Event('input', { bubbles: true }));
160
+ node.dispatchEvent(new Event('change', { bubbles: true }));
161
+ return { ok: true, value: node instanceof HTMLSelectElement ? text(node.selectedOptions[0]?.label) : node.value };
103
162
  },
104
163
  };
105
164
  }
106
- /** One disposable snapshot, owned by the operation that observed it. No DOM attributes are injected. */
165
+ // Large enough to hold any frame, so offsets are computed without clipping.
166
+ const UNCLIPPED = { x: -1e9, y: -1e9, width: 2e9, height: 2e9 };
167
+ // Where a frame's content box sits in main-viewport coordinates, clipped to what is visible.
168
+ async function frameArea(frame, viewport) {
169
+ const parent = frame.parentFrame();
170
+ if (!parent)
171
+ return { ...viewport, dx: 0, dy: 0 };
172
+ const outer = await frameArea(parent, viewport);
173
+ const owner = outer && await frame.frameElement().catch(() => null);
174
+ if (!outer || !owner)
175
+ return null;
176
+ try {
177
+ const box = await owner.evaluate(node => {
178
+ const element = node;
179
+ // An iframe that is invisible, hidden, or inert hides its controls, as it would a control of its own.
180
+ if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
181
+ || element.closest('[hidden],[inert],[aria-hidden="true"]'))
182
+ return null;
183
+ // A transformed or zoomed iframe doesn't map its coordinates by offset; don't ground its controls.
184
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
185
+ const style = getComputedStyle(ancestor);
186
+ if (style.transform !== 'none' || style.scale !== 'none' || style.rotate !== 'none'
187
+ || style.translate !== 'none' || (style.zoom && style.zoom !== '1'))
188
+ return null;
189
+ }
190
+ const rect = element.getBoundingClientRect(), style = getComputedStyle(element);
191
+ const left = rect.left + element.clientLeft + parseFloat(style.paddingLeft);
192
+ const top = rect.top + element.clientTop + parseFloat(style.paddingTop);
193
+ return { left, top, width: element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
194
+ height: element.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom) };
195
+ });
196
+ if (!box)
197
+ return null;
198
+ const dx = outer.dx + box.left, dy = outer.dy + box.top;
199
+ const x = Math.max(outer.x, dx), y = Math.max(outer.y, dy);
200
+ const width = Math.min(outer.x + outer.width, dx + box.width) - x, height = Math.min(outer.y + outer.height, dy + box.height) - y;
201
+ return width > 0 && height > 0 ? { x, y, width, height, dx, dy } : null;
202
+ }
203
+ finally {
204
+ await owner.dispose();
205
+ }
206
+ }
207
+ // Each ancestor frame must show its iframe at the point; another element covering it would take the click.
208
+ async function frameVisibleAt(frame, x, y) {
209
+ const parent = frame.parentFrame();
210
+ if (!parent)
211
+ return true;
212
+ const owner = await frame.frameElement().catch(() => null);
213
+ if (!owner)
214
+ return false;
215
+ try {
216
+ const local = await frameArea(parent, UNCLIPPED);
217
+ if (!local)
218
+ return false;
219
+ const hit = await owner.evaluate((element, point) => document.elementFromPoint(point.x, point.y) === element, { x: x - local.dx, y: y - local.dy });
220
+ return hit && await frameVisibleAt(parent, x, y);
221
+ }
222
+ finally {
223
+ await owner.dispose();
224
+ }
225
+ }
226
+ /** One disposable snapshot per frame, owned by the operation that observed it. No DOM attributes are injected. */
107
227
  export class GroundedControls {
108
228
  snapshot;
109
229
  async clear() {
110
230
  const snapshot = this.snapshot;
111
231
  this.snapshot = undefined;
112
- await snapshot?.handle.dispose();
232
+ await Promise.all(snapshot?.handles.map(handle => handle.dispose().catch(() => { })) ?? []);
113
233
  }
114
234
  async observe(page) {
115
235
  await this.clear();
116
236
  checkOperation();
237
+ const scope = 'viewport-links-buttons-and-native-fields';
117
238
  const operation = currentOperation();
118
239
  if (!operation)
119
- return { controls: [], truncated: false, scope: 'viewport-native-links-and-buttons' };
120
- const handle = await page.evaluateHandle(captureControls);
240
+ return { controls: [], truncated: false, scope };
241
+ const size = page.viewportSize() ?? await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
242
+ const viewport = { x: 0, y: 0, ...size };
243
+ const handles = [], entries = [];
244
+ const candidates = [];
245
+ let truncated = false;
121
246
  const prefix = randomUUID();
122
- this.snapshot = { handle, page, operation, prefix };
247
+ this.snapshot = { handles, entries, page, operation, prefix };
248
+ for (const frame of page.frames()) {
249
+ const area = await frameArea(frame, viewport).catch(() => null);
250
+ if (!area)
251
+ continue;
252
+ const handle = await frame.evaluateHandle(captureControls, FIELDS).catch(() => null);
253
+ checkOperation();
254
+ if (!handle)
255
+ continue;
256
+ handles.push(handle);
257
+ const data = await handle.evaluate(({ controls, truncated }) => ({ controls, truncated })).catch(() => null);
258
+ if (!data)
259
+ continue;
260
+ truncated ||= data.truncated;
261
+ data.controls.forEach(({ box, ...control }, index) => {
262
+ const x = area.dx + box.x, y = area.dy + box.y;
263
+ // Only controls fully inside the frame's visible area of the main viewport.
264
+ if (x < area.x || y < area.y || x + box.width > area.x + area.width || y + box.height > area.y + area.height)
265
+ return;
266
+ candidates.push({ frame, handle, index, control });
267
+ });
268
+ }
123
269
  checkOperation();
124
- const data = await handle.evaluate(({ controls, truncated }) => ({ controls, truncated }));
125
- return { scope: 'viewport-native-links-and-buttons', truncated: data.truncated,
126
- controls: data.controls.map((control, index) => ({ ref: `${prefix}:${index}`, ...control })) };
270
+ // Count descriptions before limiting the list, so truncation can't hide a duplicate.
271
+ const key = (control) => JSON.stringify([control.role, control.label, control.context]);
272
+ const counts = new Map();
273
+ for (const { control } of candidates)
274
+ counts.set(key(control), (counts.get(key(control)) ?? 0) + 1);
275
+ const listed = [];
276
+ let bytes = 0;
277
+ for (const { frame, handle, index, control } of candidates) {
278
+ bytes += new TextEncoder().encode(JSON.stringify(control)).length + 100; // Reserve reference/JSON overhead.
279
+ if (listed.length === 64 || bytes > 16_000) {
280
+ truncated = true;
281
+ break;
282
+ }
283
+ const ambiguous = counts.get(key(control)) > 1;
284
+ listed.push({ ...control, ambiguous });
285
+ entries.push({ frame, handle, index, role: control.role, ambiguous });
286
+ }
287
+ return { scope, truncated, controls: listed.map((control, index) => ({ ref: `${prefix}:${index}`, ...control })) };
127
288
  }
128
- async click(harness, ref) {
289
+ entry(ref) {
129
290
  const snapshot = this.snapshot;
130
291
  const index = Number(ref.slice(ref.lastIndexOf(':') + 1));
131
292
  if (!snapshot || snapshot.operation !== currentOperation() || !Number.isInteger(index)
132
- || ref !== `${snapshot.prefix}:${index}`)
293
+ || ref !== `${snapshot.prefix}:${index}` || !snapshot.entries[index] || snapshot.entries[index].ambiguous)
294
+ return null;
295
+ return { snapshot, entry: snapshot.entries[index] };
296
+ }
297
+ async click(harness, ref) {
298
+ const found = this.entry(ref);
299
+ if (!found)
133
300
  return false;
301
+ const { snapshot, entry } = found;
134
302
  return harness.clickGrounded(async () => {
135
303
  checkOperation();
136
304
  if (this.snapshot !== snapshot || harness.page !== snapshot.page)
137
305
  return null;
138
306
  try {
139
- return await snapshot.handle.evaluate((state, index) => state.resolve(index), index);
307
+ const area = await frameArea(entry.frame, UNCLIPPED);
308
+ const point = area && await entry.handle.evaluate((state, index) => state.point(index), entry.index);
309
+ if (!area || !point)
310
+ return null;
311
+ const x = area.dx + point.x, y = area.dy + point.y;
312
+ return await frameVisibleAt(entry.frame, x, y) ? { x, y } : null;
140
313
  }
141
314
  catch {
142
315
  checkOperation();
@@ -144,4 +317,39 @@ export class GroundedControls {
144
317
  } // A destroyed document rejects the read; never replay a click.
145
318
  });
146
319
  }
320
+ /** Sets a native select or date-like input through the DOM, where its picker can't be seen. */
321
+ async setValue(harness, ref, value, kind) {
322
+ const found = this.entry(ref);
323
+ if (!found || (kind === 'select') !== (found.entry.role === 'select'))
324
+ return GROUNDED_INPUT_REJECTED;
325
+ const { snapshot, entry } = found;
326
+ const args = { index: entry.index, kind, value };
327
+ try {
328
+ // Nothing has changed yet, so any failure here is a rejection.
329
+ checkOperation();
330
+ if (this.snapshot !== snapshot || harness.page !== snapshot.page)
331
+ return GROUNDED_INPUT_REJECTED;
332
+ const area = await frameArea(entry.frame, UNCLIPPED);
333
+ const checked = area && await entry.handle.evaluate((state, { index, kind, value }) => state.check(index, kind, value), args);
334
+ if (!area || !checked)
335
+ return GROUNDED_INPUT_REJECTED;
336
+ if (!checked.ok)
337
+ return REJECTIONS[checked.reason];
338
+ if (!await frameVisibleAt(entry.frame, area.dx + checked.point.x, area.dy + checked.point.y))
339
+ return GROUNDED_INPUT_REJECTED;
340
+ }
341
+ catch {
342
+ checkOperation();
343
+ return GROUNDED_INPUT_REJECTED;
344
+ }
345
+ checkOperation();
346
+ if (this.snapshot !== snapshot)
347
+ return GROUNDED_INPUT_REJECTED;
348
+ // The field may change from here, so failures propagate as unknown outcomes rather than rejections.
349
+ const applied = await entry.handle.evaluate((state, { index, kind, value }) => state.apply(index, kind, value), args);
350
+ if (!applied.ok)
351
+ return REJECTIONS[applied.reason];
352
+ await harness.waitForStability();
353
+ return { changed: true, value: applied.value };
354
+ }
147
355
  }
package/dist/web/tabs.js CHANGED
@@ -210,13 +210,6 @@ export class TabManager {
210
210
  logger.debug(`Active tab changed to: ${page.url()}`);
211
211
  this.activePage = page;
212
212
  this.events.emit('tabChanged', page);
213
- page.removeAllListeners('framenavigated');
214
- page.on('framenavigated', async (frame) => {
215
- if (frame === page.mainFrame()) {
216
- // const url = frame.url();
217
- // await logNavigation(url);
218
- }
219
- });
220
213
  }
221
214
  async switchTab(index) {
222
215
  const pages = this.context.pages();
@@ -1,4 +1,5 @@
1
1
  import getShadowDOMInputAdapterScript from './scripts/shadowDOMInputAdapter';
2
+ import { renderSelectPickersInFrame } from './baseSelect';
2
3
  import logger from '@/logger';
3
4
  export class DOMTransformer {
4
5
  initializedPages = new WeakSet(); // Track pages for which 'load' listener is set
@@ -10,12 +11,20 @@ export class DOMTransformer {
10
11
  // Pass 'newPage' (the page that triggered the 'load' event) to setupScriptForPage.
11
12
  await this.setupScriptForPage(newPage);
12
13
  });
14
+ // An iframe can navigate without a page 'load' event; its selects still need the picker style.
15
+ newPage.on('framenavigated', async (frame) => {
16
+ if (!frame.parentFrame())
17
+ return;
18
+ await frame.waitForLoadState('domcontentloaded').catch(() => { });
19
+ await renderSelectPickersInFrame(frame);
20
+ });
13
21
  this.initializedPages.add(newPage); // Mark this Page object as having its 'load' listener set up.
14
22
  // A page that loaded before it was tracked never fires 'load' again.
15
23
  void this.setupScriptForPage(newPage);
16
24
  }
17
25
  }
18
26
  async setupScriptForPage(targetPage) {
27
+ await Promise.all(targetPage.frames().map(renderSelectPickersInFrame));
19
28
  try {
20
29
  // Check if a marker for the script already exists on the page for this load cycle.
21
30
  const scriptAlreadyInjected = await targetPage.evaluate(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddwang/magnitude-core",
3
- "version": "0.3.1-ddwang.13",
3
+ "version": "0.3.1-ddwang.14",
4
4
  "description": "Magnitude e2e testing agent",
5
5
  "publishConfig": {
6
6
  "access": "public"