@e2e-dev/decision 0.2.0-nightly-20261009094406 → 0.2.0
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/README.md +14 -21
- package/dist/.build.json +1 -1
- package/dist/budget.d.ts +19 -0
- package/dist/budget.js +29 -0
- package/dist/decide.d.ts +8 -1
- package/dist/decide.js +70 -37
- package/dist/dispatch.d.ts +37 -0
- package/dist/dispatch.js +67 -0
- package/dist/elements.d.ts +32 -7
- package/dist/elements.js +153 -74
- package/dist/executor.js +300 -215
- package/dist/hash.d.ts +2 -0
- package/dist/hash.js +21 -0
- package/dist/locator.d.ts +38 -0
- package/dist/locator.js +91 -0
- package/dist/overlay.d.ts +59 -0
- package/dist/overlay.js +121 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +39 -0
- package/dist/picks.d.ts +36 -0
- package/dist/picks.js +49 -0
- package/dist/questions.d.ts +56 -35
- package/dist/questions.js +203 -112
- package/dist/text.d.ts +31 -5
- package/dist/text.js +67 -53
- package/dist/types.d.ts +12 -0
- package/package.json +10 -7
package/dist/elements.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fnv1a } from './hash.js';
|
|
1
2
|
/** TypeSafe's per-question choice limit; the AI SDK itself has none. */
|
|
2
3
|
const MAX_CHOICES = 255;
|
|
3
4
|
const tappable = new Set([
|
|
@@ -13,11 +14,40 @@ const tappable = new Set([
|
|
|
13
14
|
const typable = new Set(['textbox', 'searchbox', 'spinbutton', 'combobox']);
|
|
14
15
|
const secretTypable = new Set(['textbox', 'searchbox', 'combobox']);
|
|
15
16
|
const checkable = new Set(['checkbox', 'radio', 'switch']);
|
|
17
|
+
/** Live regions whose text reports what the app just did; a log can run long, so each line and their count are bounded. */
|
|
18
|
+
const liveRegions = new Set(['status', 'alert', 'log']);
|
|
19
|
+
const MAX_STATUSES = 20;
|
|
20
|
+
const MAX_STATUS_LENGTH = 300;
|
|
21
|
+
/** Roles that can receive a dropped node. */
|
|
22
|
+
const droppable = new Set(['region', 'list', 'listitem', 'group', 'cell', 'gridcell', 'row', 'article', 'section', 'tabpanel']);
|
|
23
|
+
/**
|
|
24
|
+
* Roles of nodes that are no control yet take a pointer: a card that opens
|
|
25
|
+
* a menu on hover, a file row that takes a right-click, a paragraph to
|
|
26
|
+
* scroll to, a column to drop on. The empty role is a plain text node.
|
|
27
|
+
*/
|
|
28
|
+
const passiveRoles = new Set([
|
|
29
|
+
'',
|
|
30
|
+
'listitem',
|
|
31
|
+
'article',
|
|
32
|
+
'region',
|
|
33
|
+
'group',
|
|
34
|
+
'cell',
|
|
35
|
+
'gridcell',
|
|
36
|
+
'row',
|
|
37
|
+
'img',
|
|
38
|
+
'figure',
|
|
39
|
+
'heading',
|
|
40
|
+
'paragraph',
|
|
41
|
+
'list',
|
|
42
|
+
'section',
|
|
43
|
+
'tabpanel',
|
|
44
|
+
]);
|
|
16
45
|
/** Builds the element table and bound targets from the newest observation. */
|
|
17
46
|
export function actionSpace(ctx, observation, canType) {
|
|
18
47
|
const verbs = ctx.target.verbs;
|
|
19
48
|
const rows = [];
|
|
20
49
|
const pageText = [];
|
|
50
|
+
const statuses = [];
|
|
21
51
|
/** Walks the tree, collecting interactive rows and page text. */
|
|
22
52
|
const visit = (node, underNativeSelect) => {
|
|
23
53
|
if (node.states?.disabled !== true && node.states?.hidden !== true) {
|
|
@@ -27,9 +57,11 @@ export function actionSpace(ctx, observation, canType) {
|
|
|
27
57
|
const nativeSelect = role === 'combobox' && (node.children ?? []).some((child) => child.role === 'option');
|
|
28
58
|
const childSelect = underNativeSelect || nativeSelect;
|
|
29
59
|
const checkedRadio = role === 'radio' && node.states?.checked === true;
|
|
30
|
-
|
|
60
|
+
const fileInput = node.attributes?.['type'] === 'file';
|
|
61
|
+
const inViewport = intersects(node, observation.viewport);
|
|
62
|
+
const pressable = tappable.has(role) && !checkable.has(role) && !(role === 'option' && childSelect) && !fileInput;
|
|
63
|
+
if (verbs.has('tap') && pressable)
|
|
31
64
|
operations.push('tap');
|
|
32
|
-
}
|
|
33
65
|
if (typable.has(role) && !nativeSelect) {
|
|
34
66
|
if (!password && verbs.has('type') && canType)
|
|
35
67
|
operations.push('type');
|
|
@@ -44,34 +76,78 @@ export function actionSpace(ctx, observation, canType) {
|
|
|
44
76
|
// `check` toggles, so a checked radio would be unchecked: offer radios only when unchecked.
|
|
45
77
|
if (verbs.has('check') && checkable.has(role) && !checkedRadio)
|
|
46
78
|
operations.push('check');
|
|
79
|
+
// Paths come from the text model, as typed values do.
|
|
80
|
+
if (verbs.has('upload') && fileInput && canType)
|
|
81
|
+
operations.push('upload');
|
|
82
|
+
const interactive = operations.length > 0;
|
|
83
|
+
if (pressable) {
|
|
84
|
+
if (verbs.has('doubleTap'))
|
|
85
|
+
operations.push('double_tap');
|
|
86
|
+
if (verbs.has('longPress'))
|
|
87
|
+
operations.push('long_press');
|
|
88
|
+
}
|
|
89
|
+
// Pointer and scroll operations reach any labeled node, not only
|
|
90
|
+
// controls: a card menu that opens on hover, a file row that takes a
|
|
91
|
+
// right-click, a footnote to bring into view.
|
|
92
|
+
const pointable = (interactive || (passiveRoles.has(role) && !childSelect)) && nodeLabel(node) !== '' && node !== observation.tree;
|
|
93
|
+
if (pointable) {
|
|
94
|
+
if (verbs.has('hover'))
|
|
95
|
+
operations.push('hover');
|
|
96
|
+
if (verbs.has('secondaryTap'))
|
|
97
|
+
operations.push('secondary_tap');
|
|
98
|
+
if (verbs.has('drag'))
|
|
99
|
+
operations.push('drag');
|
|
100
|
+
if (verbs.has('scrollTo') && node.rect !== undefined && !inViewport)
|
|
101
|
+
operations.push('scroll_to');
|
|
102
|
+
}
|
|
47
103
|
if (operations.length > 0) {
|
|
48
|
-
rows.push({ node, operations, inViewport:
|
|
104
|
+
rows.push({ node, operations, inViewport, passive: !interactive });
|
|
49
105
|
}
|
|
50
|
-
|
|
106
|
+
if (!interactive) {
|
|
51
107
|
const line = pageLine(node);
|
|
52
108
|
// A checked radio leaves the table, so its state rides on the page text.
|
|
53
109
|
if (line !== '')
|
|
54
110
|
pageText.push(checkedRadio ? `${line} (checked)` : line);
|
|
111
|
+
if (liveRegions.has(role) && statuses.length < MAX_STATUSES) {
|
|
112
|
+
const text = (node.text ?? '').replace(/\s+/g, ' ').trim();
|
|
113
|
+
if (text !== '')
|
|
114
|
+
statuses.push(clip(`${(node.name ?? '').trim() || role}: ${text}`, MAX_STATUS_LENGTH));
|
|
115
|
+
}
|
|
55
116
|
}
|
|
56
117
|
}
|
|
57
118
|
for (const child of node.children ?? [])
|
|
58
119
|
visit(child, underNativeSelect || node.role === 'combobox');
|
|
59
120
|
};
|
|
60
121
|
visit(observation.tree, false);
|
|
61
|
-
//
|
|
62
|
-
// the model can scroll to them,
|
|
122
|
+
// Controls before passive rows, viewport nodes first within each, then
|
|
123
|
+
// tree order; drop the rest past the cap so the model can scroll to them,
|
|
124
|
+
// and never block on a large screen. A control below the fold still
|
|
125
|
+
// outranks any amount of visible prose.
|
|
63
126
|
const ordered = [...rows]
|
|
64
127
|
.map((row, order) => ({ row, order }))
|
|
65
|
-
.toSorted((a, b) => Number(b.row.inViewport) - Number(a.row.inViewport) || a.order - b.order)
|
|
128
|
+
.toSorted((a, b) => Number(a.row.passive) - Number(b.row.passive) || Number(b.row.inViewport) - Number(a.row.inViewport) || a.order - b.order)
|
|
66
129
|
.map(({ row }) => row);
|
|
67
130
|
const omitted = Math.max(0, ordered.length - MAX_CHOICES);
|
|
68
131
|
const kept = ordered.slice(0, MAX_CHOICES);
|
|
132
|
+
// Drop targets come from the kept rows: a drag with nowhere to drop is no operation.
|
|
133
|
+
const destinations = new Map();
|
|
134
|
+
if (verbs.has('drag')) {
|
|
135
|
+
for (const [position, row] of kept.entries()) {
|
|
136
|
+
const role = row.node.role ?? '';
|
|
137
|
+
const label = clip(nodeLabel(row.node), 120);
|
|
138
|
+
if (droppable.has(role) && label !== '')
|
|
139
|
+
destinations.set(String(position + 1), { id: row.node.id, label, role });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
69
142
|
const targets = new Map();
|
|
143
|
+
const byIndex = new Map();
|
|
70
144
|
let selectCount = 0;
|
|
71
145
|
const elements = kept.map((row, position) => {
|
|
72
146
|
const index = String(position + 1);
|
|
73
147
|
const label = clip(nodeLabel(row.node), 120);
|
|
74
|
-
|
|
148
|
+
const role = row.node.role ?? '';
|
|
149
|
+
const operations = destinations.size === 0 ? row.operations.filter((operation) => operation !== 'drag') : row.operations;
|
|
150
|
+
for (const operation of operations) {
|
|
75
151
|
if (operation === 'select') {
|
|
76
152
|
const options = new Map();
|
|
77
153
|
for (const [optionIndex, child] of (row.node.children ?? []).entries()) {
|
|
@@ -88,11 +164,7 @@ export function actionSpace(ctx, observation, canType) {
|
|
|
88
164
|
continue;
|
|
89
165
|
const key = `${index}:${optionIndex}`;
|
|
90
166
|
const optionLabel = child.name ?? child.text ?? '';
|
|
91
|
-
options.set(key, {
|
|
92
|
-
description: `select option ${JSON.stringify(optionLabel)} in ${label} [${row.node.id}]`,
|
|
93
|
-
optionLabel,
|
|
94
|
-
run: () => ctx.actions.select({ id: row.node.id }, optionLabel),
|
|
95
|
-
});
|
|
167
|
+
options.set(key, { id: row.node.id, description: `select option ${JSON.stringify(optionLabel)} in ${named(role, label)}`, optionLabel });
|
|
96
168
|
selectCount += 1;
|
|
97
169
|
}
|
|
98
170
|
if (options.size > 0) {
|
|
@@ -103,57 +175,81 @@ export function actionSpace(ctx, observation, canType) {
|
|
|
103
175
|
}
|
|
104
176
|
}
|
|
105
177
|
else {
|
|
106
|
-
const key = index;
|
|
107
|
-
const target = bind(row.node, operation, label, ctx);
|
|
108
178
|
const group = targets.get(operation) ?? new Map();
|
|
109
|
-
group.set(
|
|
179
|
+
group.set(index, {
|
|
180
|
+
id: row.node.id,
|
|
181
|
+
description: `${VERBS[operation]} ${named(role, label)}`,
|
|
182
|
+
...(row.node.states?.checked === undefined ? {} : { checked: row.node.states.checked }),
|
|
183
|
+
});
|
|
110
184
|
targets.set(operation, group);
|
|
111
185
|
}
|
|
112
186
|
}
|
|
113
|
-
|
|
187
|
+
const element = {
|
|
114
188
|
index,
|
|
115
|
-
role
|
|
189
|
+
role,
|
|
116
190
|
label,
|
|
117
191
|
...(row.node.value === undefined ? {} : { value: row.node.value }),
|
|
118
192
|
...(row.node.states?.checked === undefined ? {} : { checked: row.node.states.checked }),
|
|
119
193
|
...(row.node.states?.expanded === undefined ? {} : { expanded: row.node.states.expanded }),
|
|
120
|
-
operations
|
|
194
|
+
operations,
|
|
121
195
|
};
|
|
196
|
+
byIndex.set(index, element);
|
|
197
|
+
return element;
|
|
122
198
|
});
|
|
123
|
-
const
|
|
199
|
+
const tapAt = observation.pixels !== undefined && observation.locates === true && verbs.has('tapAt');
|
|
200
|
+
const controls = new Set();
|
|
124
201
|
if (verbs.has('scroll')) {
|
|
125
|
-
controls.
|
|
126
|
-
controls.
|
|
127
|
-
}
|
|
128
|
-
if (verbs.has('back')) {
|
|
129
|
-
controls.set('back', { description: 'back one step in history', run: () => ctx.actions.back() });
|
|
202
|
+
controls.add('scroll_up');
|
|
203
|
+
controls.add('scroll_down');
|
|
130
204
|
}
|
|
205
|
+
if (verbs.has('back'))
|
|
206
|
+
controls.add('back');
|
|
131
207
|
return {
|
|
132
208
|
elements,
|
|
209
|
+
element: (key) => byIndex.get(targetKeyIndex(key)),
|
|
133
210
|
targets,
|
|
134
211
|
controls,
|
|
212
|
+
operation: (choice) => (targets.has(choice) || (tapAt && choice === 'tap_at') ? choice : undefined),
|
|
213
|
+
control: (choice) => (controls.has(choice) ? choice : undefined),
|
|
214
|
+
destinations,
|
|
215
|
+
tapAt,
|
|
135
216
|
omitted,
|
|
136
217
|
pageText: clip(pageText.join('\n'), 6000),
|
|
137
|
-
|
|
218
|
+
statuses,
|
|
219
|
+
fingerprint: fingerprint(observation.path ?? '', observation.tree, observation.viewport, observation.pixels),
|
|
138
220
|
};
|
|
139
221
|
}
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
/**
|
|
223
|
+
* A node as an action line names it: `checkbox "Agree to terms"`. Measured
|
|
224
|
+
* on gpt-6-luna over captured requests, the role and the quoted label lift
|
|
225
|
+
* a completion verdict the bare label with a node id left inconclusive
|
|
226
|
+
* (holds 0.17 to 0.83 on a checked checkbox), with no loss on the
|
|
227
|
+
* operation questions.
|
|
228
|
+
*/
|
|
229
|
+
function named(role, label) {
|
|
230
|
+
return role === '' ? JSON.stringify(label) : `${role} ${JSON.stringify(label)}`;
|
|
231
|
+
}
|
|
232
|
+
/** How each operation reads in a target description and in history. */
|
|
233
|
+
const VERBS = {
|
|
234
|
+
select: 'select',
|
|
235
|
+
tap_at: 'tap at',
|
|
236
|
+
tap: 'tap',
|
|
237
|
+
type: 'type into',
|
|
238
|
+
typeSecret: 'typeSecret into',
|
|
239
|
+
submit: 'submit',
|
|
240
|
+
check: 'check',
|
|
241
|
+
hover: 'hover',
|
|
242
|
+
secondary_tap: 'right-click',
|
|
243
|
+
double_tap: 'double-tap',
|
|
244
|
+
long_press: 'long-press',
|
|
245
|
+
scroll_to: 'scroll to',
|
|
246
|
+
upload: 'upload to',
|
|
247
|
+
drag: 'drag',
|
|
248
|
+
};
|
|
249
|
+
/** Element index behind a target key (`7` for both `7` and `7:2`). */
|
|
250
|
+
export function targetKeyIndex(key) {
|
|
251
|
+
const at = key.indexOf(':');
|
|
252
|
+
return at === -1 ? key : key.slice(0, at);
|
|
157
253
|
}
|
|
158
254
|
/** Label the model reads: name, placeholder, or text. */
|
|
159
255
|
function nodeLabel(node) {
|
|
@@ -183,41 +279,24 @@ function clip(text, limit) {
|
|
|
183
279
|
return text.length > limit ? text.slice(0, limit) : text;
|
|
184
280
|
}
|
|
185
281
|
/**
|
|
186
|
-
* Stable hash of the path, the tree content with node ids removed,
|
|
187
|
-
*
|
|
188
|
-
* stay out. In-view membership lets a
|
|
189
|
-
* view count as progress, while one
|
|
190
|
-
* still reads as unchanged; raw
|
|
191
|
-
* do not count.
|
|
282
|
+
* Stable hash of the path, the tree content with node ids removed, whether
|
|
283
|
+
* each node meets the viewport, and the pixels when the step sees them. Ids
|
|
284
|
+
* change on every capture, so they stay out. In-view membership lets a
|
|
285
|
+
* scroll that brings other nodes into view count as progress, while one
|
|
286
|
+
* that moves nothing (the page bottom) still reads as unchanged; raw
|
|
287
|
+
* coordinates stay out so small layout shifts do not count. Pixels are in
|
|
288
|
+
* because a drawn control changes nothing in the tree: a keypad digit
|
|
289
|
+
* entered on a canvas is progress only the screenshot shows.
|
|
192
290
|
*/
|
|
193
|
-
function fingerprint(path, tree, viewport) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
hash ^= text.charCodeAt(index);
|
|
198
|
-
hash = Math.imul(hash, 16777619);
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
feed(path);
|
|
202
|
-
feed('\0');
|
|
291
|
+
function fingerprint(path, tree, viewport, pixels) {
|
|
292
|
+
const parts = [path];
|
|
293
|
+
if (pixels !== undefined)
|
|
294
|
+
parts.push(pixels.data);
|
|
203
295
|
const visit = (node) => {
|
|
204
|
-
|
|
205
|
-
feed('\0');
|
|
206
|
-
feed(node.name ?? '');
|
|
207
|
-
feed('\0');
|
|
208
|
-
feed(node.text ?? '');
|
|
209
|
-
feed('\0');
|
|
210
|
-
feed(node.value ?? '');
|
|
211
|
-
feed('\0');
|
|
212
|
-
feed(JSON.stringify(node.states ?? null));
|
|
213
|
-
feed('\0');
|
|
214
|
-
feed(JSON.stringify(node.attributes ?? null));
|
|
215
|
-
feed('\0');
|
|
216
|
-
feed(intersects(node, viewport) ? 'v' : '-');
|
|
217
|
-
feed('\0');
|
|
296
|
+
parts.push(node.role ?? '', node.name ?? '', node.text ?? '', node.value ?? '', JSON.stringify(node.states ?? null), JSON.stringify(node.attributes ?? null), intersects(node, viewport) ? 'v' : '-');
|
|
218
297
|
for (const child of node.children ?? [])
|
|
219
298
|
visit(child);
|
|
220
299
|
};
|
|
221
300
|
visit(tree);
|
|
222
|
-
return (
|
|
301
|
+
return fnv1a(parts);
|
|
223
302
|
}
|