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

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,10 +5,38 @@ 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
- private snapshot?;
34
+ private snapshots;
35
+ private fieldChanged;
11
36
  clear(): Promise<void>;
37
+ /** Called before planning: the planner sees only the latest snapshot. */
38
+ keepLatest(): Promise<void>;
39
+ private release;
12
40
  observe(page: Page): Promise<{
13
41
  scope: string;
14
42
  truncated: boolean;
@@ -17,9 +45,18 @@ export declare class GroundedControls {
17
45
  label: string;
18
46
  context: string;
19
47
  enabled: boolean;
48
+ value?: string;
49
+ options?: string[];
20
50
  ambiguous: boolean;
21
51
  ref: string;
22
52
  }[];
23
53
  }>;
54
+ private entry;
24
55
  click(harness: WebHarness, ref: string): Promise<boolean>;
56
+ /** Sets a native select or date-like input through the DOM, where its picker can't be seen. */
57
+ setValue(harness: WebHarness, ref: string, value: string, kind: 'select' | 'fill'): Promise<{
58
+ changed: true;
59
+ value: string;
60
+ } | (typeof REJECTIONS)[keyof typeof REJECTIONS]>;
25
61
  }
62
+ 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,11 +24,27 @@ 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
+ // Read form attributes through the prototype: a control named "action" would shadow form.action.
29
+ const formProperty = (form, key) => Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, key).get.call(form);
30
+ // Options a person could choose: neither the option nor its group is disabled or hidden.
31
+ // Computed styles work while the picker is closed; layout-based visibility checks do not.
32
+ const concealed = (element) => {
33
+ const style = getComputedStyle(element);
34
+ return element.hidden || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
35
+ };
36
+ const eligible = (select) => Array.from(select.options).filter(option => {
37
+ const group = option.parentElement instanceof HTMLOptGroupElement ? option.parentElement : null;
38
+ return !option.disabled && !concealed(option) && !(group && (group.disabled || concealed(group)));
39
+ });
16
40
  function describe(node) {
17
- if (!(node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement)
41
+ const field = node.matches(fields) ? node : null;
42
+ const submit = node instanceof HTMLInputElement && node.type === 'submit' ? node : null;
43
+ if (!(field || submit || node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement)
18
44
  || !node.isConnected || !node.checkVisibility(visibility) || node.closest('[hidden],[inert],[aria-hidden="true"]'))
19
45
  return null;
20
- if (node instanceof HTMLButtonElement && node.form && node.type !== 'button')
46
+ // Submit buttons are listed; reset buttons only discard input.
47
+ if (node instanceof HTMLButtonElement && node.type === 'reset')
21
48
  return null;
22
49
  if (node instanceof HTMLAnchorElement && !/^https?:$/.test(node.protocol))
23
50
  return null;
@@ -26,70 +53,82 @@ function captureControls() {
26
53
  || box.right > innerWidth || box.bottom > innerHeight)
27
54
  return null;
28
55
  const labelledBy = node.getAttribute('aria-labelledby');
29
- const label = (labelledBy
56
+ const label = text(labelledBy
30
57
  ? labelledBy.split(/\s+/).map(id => document.getElementById(id)?.textContent ?? '').join(' ')
31
- : node.getAttribute('aria-label') || node.innerText || node.getAttribute('title') || '').trim();
58
+ : node.getAttribute('aria-label') || (field
59
+ // A wrapping label also contains the field's own text, such as its option labels.
60
+ ? Array.from(field.labels?.[0]?.childNodes ?? []).filter(child => !(child instanceof Element && child.matches('input,select,textarea,button')))
61
+ .map(child => child.textContent).join(' ').trim() || node.getAttribute('title') || node.getAttribute('name')
62
+ : (submit ? submit.value : node.innerText) || node.getAttribute('title')));
32
63
  if (!label || label.length > 256)
33
64
  return null;
34
65
  const container = node.closest('tr,[role="row"],li,fieldset,form,dialog,[role="dialog"],section,article');
35
- const context = (container?.matches('tr,[role="row"],li')
66
+ const context = text(container?.matches('tr,[role="row"],li')
36
67
  ? container.innerText
37
- : container?.getAttribute('aria-label') || container?.querySelector(':scope > legend,:scope > h1,:scope > h2,:scope > h3')?.textContent || '').trim();
68
+ : container?.getAttribute('aria-label') || container?.querySelector(':scope > legend,:scope > h1,:scope > h2,:scope > h3')?.textContent);
38
69
  if (context.length > 256)
39
70
  return null;
40
- const enabled = !node.matches(':disabled') && !node.closest('[aria-disabled="true"]');
41
- const role = node.getAttribute('role') || (node instanceof HTMLAnchorElement ? 'link' : 'button');
71
+ const enabled = !node.matches(':disabled') && !node.closest('[aria-disabled="true"]') && !field?.readOnly;
72
+ const role = field instanceof HTMLSelectElement ? 'select' : field ? field.type
73
+ : node.getAttribute('role') || (node instanceof HTMLAnchorElement ? 'link' : 'button');
74
+ const value = field instanceof HTMLSelectElement ? text(field.selectedOptions[0]?.label) : field ? field.value : undefined;
75
+ const form = node instanceof HTMLButtonElement || field || submit ? node.form : null;
76
+ // Where a submit control sends its form: its own overrides, else the form's attributes.
77
+ const submitter = submit ?? (node instanceof HTMLButtonElement && node.type === 'submit' ? node : null);
78
+ const submission = submitter && form ? [
79
+ submitter.hasAttribute('formaction') ? submitter.formAction : formProperty(form, 'action'),
80
+ submitter.hasAttribute('formmethod') ? submitter.formMethod : formProperty(form, 'method'),
81
+ submitter.hasAttribute('formtarget') ? submitter.formTarget : formProperty(form, 'target'),
82
+ submitter.hasAttribute('formenctype') ? submitter.formEnctype : formProperty(form, 'enctype'),
83
+ submitter.formNoValidate || formProperty(form, 'noValidate'),
84
+ ] : null;
85
+ const options = field instanceof HTMLSelectElement ? eligible(field).slice(0, 40).map(option => text(option.label)) : undefined;
42
86
  // Keep activation attributes and context identity local, not in diagnostics.
43
- const identity = JSON.stringify([label, context, role, enabled,
87
+ const identity = JSON.stringify([label, context, role, enabled, node.getAttribute('name'),
44
88
  node.getAttribute('href'), node instanceof HTMLAnchorElement ? node.href : null,
45
89
  node.getAttribute('target'), node.getAttribute('download'), node.getAttribute('type'),
46
90
  node.getAttribute('popovertarget'), node.getAttribute('popovertargetaction'),
47
91
  node.getAttribute('commandfor'), node.getAttribute('command'),
48
- 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 };
92
+ node.getAttribute('aria-expanded'), node.getAttribute('aria-selected'), node.getAttribute('aria-pressed'), submission]);
93
+ return { label, context, role, enabled, value, options, identity, container, form,
94
+ box: { x: box.x, y: box.y, width: box.width, height: box.height } };
51
95
  }
52
96
  const nodes = [];
53
97
  const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT, {
54
- acceptNode: node => node.matches('a[href],button') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
98
+ acceptNode: node => node.matches(`a[href],button,input[type="submit"],${fields}`) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
55
99
  }); // Does not cross frames or shadow roots; stop collecting at the first match beyond the cap.
56
100
  while (nodes.length <= 512 && walker.nextNode())
57
101
  nodes.push(walker.currentNode);
58
102
  const entries = [];
59
- let truncated = nodes.length > 512;
103
+ const truncated = nodes.length > 512;
60
104
  if (!truncated)
61
105
  for (const node of nodes) {
62
106
  const state = describe(node);
63
107
  if (state)
64
108
  entries.push({ node, state });
65
109
  }
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);
110
+ // Re-check a reference against the node it was captured from, without dispatching anything.
111
+ function current(index) {
112
+ const entry = entries[index];
113
+ if (document !== documentAtCapture || location.href !== url || !entry)
114
+ return null;
115
+ const state = describe(entry.node);
116
+ if (!state || !state.enabled || state.container !== entry.state.container
117
+ || state.form !== entry.state.form || state.identity !== entry.state.identity)
118
+ return null;
119
+ return { entry, state };
80
120
  }
81
121
  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)
122
+ truncated,
123
+ controls: entries.map(({ state }) => ({ role: state.role, label: state.label, context: state.context, enabled: state.enabled,
124
+ ...(state.value !== undefined ? { value: state.value } : {}), ...(state.options ? { options: state.options } : {}), box: state.box })),
125
+ point(index) {
126
+ const resolved = current(index);
127
+ if (!resolved)
91
128
  return null;
92
- const hit = document.elementFromPoint(state.x, state.y);
129
+ const { entry, state } = resolved;
130
+ const x = state.box.x + state.box.width / 2, y = state.box.y + state.box.height / 2;
131
+ const hit = document.elementFromPoint(x, y);
93
132
  // Text/icon descendants belong to this control; nested controls do not.
94
133
  if (!hit || hit.closest(interactive) !== entry.node)
95
134
  return null;
@@ -99,44 +138,200 @@ function captureControls() {
99
138
  if (descendant.shadowRoot || descendant.localName.includes('-') || descendant.hasAttribute('is'))
100
139
  return null;
101
140
  }
102
- return { x: state.x, y: state.y };
141
+ return { x, y };
142
+ },
143
+ // Checks a field change without making it: the reference is current, the field receives
144
+ // input at its center, and the value is one the field accepts.
145
+ check(index, kind, value) {
146
+ const at = this.point(index);
147
+ const node = entries[index]?.node;
148
+ if (!at || !node?.matches(fields) || (kind === 'select') !== node instanceof HTMLSelectElement)
149
+ return { ok: false, reason: 'target_unavailable' };
150
+ if (node instanceof HTMLSelectElement) {
151
+ const matches = eligible(node).filter(option => text(option.label) === value);
152
+ return matches.length === 1 ? { ok: true, point: at, option: matches[0].index } : { ok: false, reason: 'option_unavailable' };
153
+ }
154
+ // A detached input of the same type sanitizes an invalid value to empty, without touching the page.
155
+ const probe = document.createElement('input');
156
+ probe.type = node.type;
157
+ probe.value = value;
158
+ return probe.value === value ? { ok: true, point: at, option: -1 } : { ok: false, reason: 'invalid_value' };
159
+ },
160
+ // Rechecks and changes the field in one step, through the native setter, then reports its value.
161
+ apply(index, kind, value) {
162
+ const checked = this.check(index, kind, value);
163
+ if (!checked.ok)
164
+ return checked;
165
+ const node = entries[index].node;
166
+ if (node instanceof HTMLSelectElement) {
167
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex').set.call(node, checked.option);
168
+ }
169
+ else {
170
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(node, value);
171
+ }
172
+ node.dispatchEvent(new Event('input', { bubbles: true }));
173
+ node.dispatchEvent(new Event('change', { bubbles: true }));
174
+ return { ok: true, value: node instanceof HTMLSelectElement ? text(node.selectedOptions[0]?.label) : node.value };
103
175
  },
104
176
  };
105
177
  }
106
- /** One disposable snapshot, owned by the operation that observed it. No DOM attributes are injected. */
178
+ // Large enough to hold any frame, so offsets are computed without clipping.
179
+ const UNCLIPPED = { x: -1e9, y: -1e9, width: 2e9, height: 2e9 };
180
+ // Where a frame's content box sits in main-viewport coordinates, clipped to what is visible.
181
+ async function frameArea(frame, viewport) {
182
+ const parent = frame.parentFrame();
183
+ if (!parent)
184
+ return { ...viewport, dx: 0, dy: 0 };
185
+ const outer = await frameArea(parent, viewport);
186
+ const owner = outer && await frame.frameElement().catch(() => null);
187
+ if (!outer || !owner)
188
+ return null;
189
+ try {
190
+ const box = await owner.evaluate(node => {
191
+ const element = node;
192
+ // An iframe that is invisible, hidden, or inert hides its controls, as it would a control of its own.
193
+ if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
194
+ || element.closest('[hidden],[inert],[aria-hidden="true"]'))
195
+ return null;
196
+ // A transformed or zoomed iframe doesn't map its coordinates by offset; don't ground its controls.
197
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
198
+ const style = getComputedStyle(ancestor);
199
+ if (style.transform !== 'none' || style.scale !== 'none' || style.rotate !== 'none'
200
+ || style.translate !== 'none' || (style.zoom && style.zoom !== '1'))
201
+ return null;
202
+ }
203
+ const rect = element.getBoundingClientRect(), style = getComputedStyle(element);
204
+ const left = rect.left + element.clientLeft + parseFloat(style.paddingLeft);
205
+ const top = rect.top + element.clientTop + parseFloat(style.paddingTop);
206
+ return { left, top, width: element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
207
+ height: element.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom) };
208
+ });
209
+ if (!box)
210
+ return null;
211
+ const dx = outer.dx + box.left, dy = outer.dy + box.top;
212
+ const x = Math.max(outer.x, dx), y = Math.max(outer.y, dy);
213
+ const width = Math.min(outer.x + outer.width, dx + box.width) - x, height = Math.min(outer.y + outer.height, dy + box.height) - y;
214
+ return width > 0 && height > 0 ? { x, y, width, height, dx, dy } : null;
215
+ }
216
+ finally {
217
+ await owner.dispose();
218
+ }
219
+ }
220
+ // Each ancestor frame must show its iframe at the point; another element covering it would take the click.
221
+ async function frameVisibleAt(frame, x, y) {
222
+ const parent = frame.parentFrame();
223
+ if (!parent)
224
+ return true;
225
+ const owner = await frame.frameElement().catch(() => null);
226
+ if (!owner)
227
+ return false;
228
+ try {
229
+ const local = await frameArea(parent, UNCLIPPED);
230
+ if (!local)
231
+ return false;
232
+ const hit = await owner.evaluate((element, point) => document.elementFromPoint(point.x, point.y) === element, { x: x - local.dx, y: y - local.dy });
233
+ return hit && await frameVisibleAt(parent, x, y);
234
+ }
235
+ finally {
236
+ await owner.dispose();
237
+ }
238
+ }
239
+ /** One disposable snapshot per frame, owned by the operation that observed it. No DOM attributes are injected. */
107
240
  export class GroundedControls {
108
- snapshot;
241
+ // The latest snapshot, preceded by the ones observed since the plan began, when only field changes happened in between.
242
+ snapshots = [];
243
+ fieldChanged = false;
109
244
  async clear() {
110
- const snapshot = this.snapshot;
111
- this.snapshot = undefined;
112
- await snapshot?.handle.dispose();
245
+ await this.release(this.snapshots.splice(0));
246
+ }
247
+ /** Called before planning: the planner sees only the latest snapshot. */
248
+ async keepLatest() {
249
+ await this.release(this.snapshots.splice(0, this.snapshots.length - 1));
250
+ }
251
+ async release(snapshots) {
252
+ await Promise.all(snapshots.flatMap(snapshot => snapshot.handles.map(handle => handle.dispose().catch(() => { }))));
113
253
  }
114
254
  async observe(page) {
115
- await this.clear();
255
+ // A successful select or fill keeps earlier refs usable for the rest of the batch; any other action expires them.
256
+ if (!this.fieldChanged)
257
+ await this.clear();
258
+ this.fieldChanged = false;
116
259
  checkOperation();
260
+ const scope = 'viewport-links-buttons-and-native-fields';
117
261
  const operation = currentOperation();
118
262
  if (!operation)
119
- return { controls: [], truncated: false, scope: 'viewport-native-links-and-buttons' };
120
- const handle = await page.evaluateHandle(captureControls);
263
+ return { controls: [], truncated: false, scope };
264
+ const size = page.viewportSize() ?? await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
265
+ const viewport = { x: 0, y: 0, ...size };
266
+ const handles = [], entries = [];
267
+ const candidates = [];
268
+ let truncated = false;
121
269
  const prefix = randomUUID();
122
- this.snapshot = { handle, page, operation, prefix };
270
+ this.snapshots.push({ handles, entries, page, operation, prefix });
271
+ for (const frame of page.frames()) {
272
+ const area = await frameArea(frame, viewport).catch(() => null);
273
+ if (!area)
274
+ continue;
275
+ const handle = await frame.evaluateHandle(captureControls, FIELDS).catch(() => null);
276
+ checkOperation();
277
+ if (!handle)
278
+ continue;
279
+ handles.push(handle);
280
+ const data = await handle.evaluate(({ controls, truncated }) => ({ controls, truncated })).catch(() => null);
281
+ if (!data)
282
+ continue;
283
+ truncated ||= data.truncated;
284
+ data.controls.forEach(({ box, ...control }, index) => {
285
+ const x = area.dx + box.x, y = area.dy + box.y;
286
+ // Only controls fully inside the frame's visible area of the main viewport.
287
+ if (x < area.x || y < area.y || x + box.width > area.x + area.width || y + box.height > area.y + area.height)
288
+ return;
289
+ candidates.push({ frame, handle, index, control });
290
+ });
291
+ }
123
292
  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 })) };
293
+ // Count descriptions before limiting the list, so truncation can't hide a duplicate.
294
+ const key = (control) => JSON.stringify([control.role, control.label, control.context]);
295
+ const counts = new Map();
296
+ for (const { control } of candidates)
297
+ counts.set(key(control), (counts.get(key(control)) ?? 0) + 1);
298
+ const listed = [];
299
+ let bytes = 0;
300
+ for (const { frame, handle, index, control } of candidates) {
301
+ bytes += new TextEncoder().encode(JSON.stringify(control)).length + 100; // Reserve reference/JSON overhead.
302
+ if (listed.length === 64 || bytes > 16_000) {
303
+ truncated = true;
304
+ break;
305
+ }
306
+ const ambiguous = counts.get(key(control)) > 1;
307
+ listed.push({ ...control, ambiguous });
308
+ entries.push({ frame, handle, index, role: control.role, ambiguous });
309
+ }
310
+ return { scope, truncated, controls: listed.map((control, index) => ({ ref: `${prefix}:${index}`, ...control })) };
127
311
  }
128
- async click(harness, ref) {
129
- const snapshot = this.snapshot;
312
+ entry(ref) {
130
313
  const index = Number(ref.slice(ref.lastIndexOf(':') + 1));
131
- if (!snapshot || snapshot.operation !== currentOperation() || !Number.isInteger(index)
132
- || ref !== `${snapshot.prefix}:${index}`)
314
+ const snapshot = this.snapshots.find(snapshot => ref === `${snapshot.prefix}:${index}`);
315
+ if (!snapshot || snapshot.operation !== currentOperation() || !snapshot.entries[index] || snapshot.entries[index].ambiguous)
316
+ return null;
317
+ return { snapshot, entry: snapshot.entries[index] };
318
+ }
319
+ async click(harness, ref) {
320
+ const found = this.entry(ref);
321
+ if (!found)
133
322
  return false;
323
+ const { snapshot, entry } = found;
134
324
  return harness.clickGrounded(async () => {
135
325
  checkOperation();
136
- if (this.snapshot !== snapshot || harness.page !== snapshot.page)
326
+ if (!this.snapshots.includes(snapshot) || harness.page !== snapshot.page)
137
327
  return null;
138
328
  try {
139
- return await snapshot.handle.evaluate((state, index) => state.resolve(index), index);
329
+ const area = await frameArea(entry.frame, UNCLIPPED);
330
+ const point = area && await entry.handle.evaluate((state, index) => state.point(index), entry.index);
331
+ if (!area || !point)
332
+ return null;
333
+ const x = area.dx + point.x, y = area.dy + point.y;
334
+ return await frameVisibleAt(entry.frame, x, y) ? { x, y } : null;
140
335
  }
141
336
  catch {
142
337
  checkOperation();
@@ -144,4 +339,40 @@ export class GroundedControls {
144
339
  } // A destroyed document rejects the read; never replay a click.
145
340
  });
146
341
  }
342
+ /** Sets a native select or date-like input through the DOM, where its picker can't be seen. */
343
+ async setValue(harness, ref, value, kind) {
344
+ const found = this.entry(ref);
345
+ if (!found || (kind === 'select') !== (found.entry.role === 'select'))
346
+ return GROUNDED_INPUT_REJECTED;
347
+ const { snapshot, entry } = found;
348
+ const args = { index: entry.index, kind, value };
349
+ try {
350
+ // Nothing has changed yet, so any failure here is a rejection.
351
+ checkOperation();
352
+ if (!this.snapshots.includes(snapshot) || harness.page !== snapshot.page)
353
+ return GROUNDED_INPUT_REJECTED;
354
+ const area = await frameArea(entry.frame, UNCLIPPED);
355
+ const checked = area && await entry.handle.evaluate((state, { index, kind, value }) => state.check(index, kind, value), args);
356
+ if (!area || !checked)
357
+ return GROUNDED_INPUT_REJECTED;
358
+ if (!checked.ok)
359
+ return REJECTIONS[checked.reason];
360
+ if (!await frameVisibleAt(entry.frame, area.dx + checked.point.x, area.dy + checked.point.y))
361
+ return GROUNDED_INPUT_REJECTED;
362
+ }
363
+ catch {
364
+ checkOperation();
365
+ return GROUNDED_INPUT_REJECTED;
366
+ }
367
+ checkOperation();
368
+ if (!this.snapshots.includes(snapshot))
369
+ return GROUNDED_INPUT_REJECTED;
370
+ // The field may change from here, so failures propagate as unknown outcomes rather than rejections.
371
+ const applied = await entry.handle.evaluate((state, { index, kind, value }) => state.apply(index, kind, value), args);
372
+ if (!applied.ok)
373
+ return REJECTIONS[applied.reason];
374
+ this.fieldChanged = true;
375
+ await harness.waitForStability();
376
+ return { changed: true, value: applied.value };
377
+ }
147
378
  }
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.15",
4
4
  "description": "Magnitude e2e testing agent",
5
5
  "publishConfig": {
6
6
  "access": "public"