@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.
- package/dist/agent/index.js +5 -2
- package/dist/ai/modelHarness.js +46 -34
- package/dist/common/operation.d.ts +6 -0
- package/dist/common/operation.test.js +2 -1
- package/dist/connectors/browserConnector.d.ts +2 -1
- package/dist/connectors/browserConnector.js +20 -3
- package/dist/connectors/index.d.ts +2 -0
- package/dist/index.cjs +345 -259
- package/dist/index.d.cts +10 -1
- package/dist/index.mjs +345 -259
- package/dist/web/baseSelect.d.ts +3 -0
- package/dist/web/baseSelect.js +20 -0
- package/dist/web/groundedControls.d.ts +39 -2
- package/dist/web/groundedControls.js +290 -59
- 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,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
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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') ||
|
|
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
|
|
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 =
|
|
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
|
-
|
|
50
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
const
|
|
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.
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
const snapshot = this.snapshot;
|
|
312
|
+
entry(ref) {
|
|
130
313
|
const index = Number(ref.slice(ref.lastIndexOf(':') + 1));
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
326
|
+
if (!this.snapshots.includes(snapshot) || harness.page !== snapshot.page)
|
|
137
327
|
return null;
|
|
138
328
|
try {
|
|
139
|
-
|
|
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();
|
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(() => {
|