@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.
- package/dist/agent/index.js +2 -2
- package/dist/connectors/browserConnector.d.ts +1 -1
- package/dist/connectors/browserConnector.js +15 -2
- package/dist/index.cjs +275 -221
- package/dist/index.d.cts +1 -1
- package/dist/index.mjs +275 -221
- package/dist/web/baseSelect.d.ts +3 -0
- package/dist/web/baseSelect.js +20 -0
- package/dist/web/groundedControls.d.ts +34 -1
- package/dist/web/groundedControls.js +258 -50
- package/dist/web/tabs.js +0 -7
- package/dist/web/transformer.js +9 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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') ||
|
|
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
|
|
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 =
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
120
|
-
const
|
|
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 = {
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/web/transformer.js
CHANGED
|
@@ -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(() => {
|