@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/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
- if (verbs.has('tap') && tappable.has(role) && !checkable.has(role) && !(role === 'option' && childSelect)) {
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: intersects(node, observation.viewport) });
104
+ rows.push({ node, operations, inViewport, passive: !interactive });
49
105
  }
50
- else {
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
- // Viewport nodes first, then tree order; drop the rest past the cap so
62
- // the model can scroll to them, and never block on a large screen.
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
- for (const operation of row.operations) {
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(key, target);
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
- return {
187
+ const element = {
114
188
  index,
115
- role: row.node.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: row.operations,
194
+ operations,
121
195
  };
196
+ byIndex.set(index, element);
197
+ return element;
122
198
  });
123
- const controls = new Map();
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.set('scroll_up', { description: 'scroll viewport up', run: () => ctx.actions.scroll('up') });
126
- controls.set('scroll_down', { description: 'scroll viewport down', run: () => ctx.actions.scroll('down') });
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
- fingerprint: fingerprint(observation.path ?? '', observation.tree, observation.viewport),
218
+ statuses,
219
+ fingerprint: fingerprint(observation.path ?? '', observation.tree, observation.viewport, observation.pixels),
138
220
  };
139
221
  }
140
- /** Binds one element operation to the runner's actions. */
141
- function bind(node, operation, label, ctx) {
142
- const target = { id: node.id };
143
- const where = `${label} [${node.id}]`;
144
- switch (operation) {
145
- case 'tap':
146
- return { description: `tap ${where}`, run: () => ctx.actions.tap(target) };
147
- case 'type':
148
- return { description: `type into ${where}`, run: (argument = '') => ctx.actions.type(target, argument) };
149
- case 'typeSecret':
150
- return { description: `typeSecret into ${where}`, run: (argument = '') => ctx.actions.typeSecret(target, argument) };
151
- case 'submit':
152
- return { description: `submit ${where}`, run: () => ctx.actions.press(target, 'Enter') };
153
- case 'check':
154
- return { description: `check ${where}`, run: () => ctx.actions.check(target, !(node.states?.checked ?? false)) };
155
- case 'select': throw new Error('select binds per option, not per element');
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, and
187
- * whether each node meets the viewport. Ids change on every capture, so they
188
- * stay out. In-view membership lets a scroll that brings other nodes into
189
- * view count as progress, while one that moves nothing (the page bottom)
190
- * still reads as unchanged; raw coordinates stay out so small layout shifts
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
- let hash = 2166136261;
195
- const feed = (text) => {
196
- for (let index = 0; index < text.length; index += 1) {
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
- feed(node.role ?? '');
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 (hash >>> 0).toString(36);
301
+ return fnv1a(parts);
223
302
  }