jevway 0.3.0 → 0.4.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/browser/code-boxes.js +121 -0
- package/dist/browser/frames.js +2 -1
- package/dist/browser/snapshot.js +29 -1
- package/dist/browser/tab.js +41 -2
- package/dist/task/run-task.js +62 -8
- package/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const MIN_BOXES = 4;
|
|
2
|
+
const MAX_BOXES = 8;
|
|
3
|
+
// One character fits in a box this wide at any common font size; a field for a word is wider.
|
|
4
|
+
const ONE_CHARACTER_PX = 64;
|
|
5
|
+
const CODE_MODES = ['numeric', 'decimal', 'text', 'tel'];
|
|
6
|
+
const CODE_TYPES = ['tel', 'number'];
|
|
7
|
+
// Words a box's own label shares with its neighbours that name no field ("Digit 1 of 6").
|
|
8
|
+
const GENERIC_WORDS = /^(?:digit|character|char|box|number|input|field|code|otp|pin|textbox|spinbutton)s?$/i;
|
|
9
|
+
const FALLBACK_NAME = 'Verification code';
|
|
10
|
+
const holdsOneCharacter = (action) => {
|
|
11
|
+
const { box, rect } = action;
|
|
12
|
+
if (action.kind !== 'fill' || !box || !rect || action.hover)
|
|
13
|
+
return false;
|
|
14
|
+
if (box.max === 1)
|
|
15
|
+
return true;
|
|
16
|
+
return box.max < 0 && rect.w <= ONE_CHARACTER_PX && (CODE_MODES.includes(box.mode) || CODE_TYPES.includes(box.type) || box.otp);
|
|
17
|
+
};
|
|
18
|
+
const centreY = (box) => box.rect.y + box.rect.h / 2;
|
|
19
|
+
// One form, one container (every box in the same parent, or each wrapped once in the same grandparent), one row, read
|
|
20
|
+
// left to right, all about the same width.
|
|
21
|
+
const joins = (run, next) => {
|
|
22
|
+
const [first] = run;
|
|
23
|
+
const last = run.at(-1);
|
|
24
|
+
const widths = [...run, next].map((box) => box.rect.w);
|
|
25
|
+
return (next.box.form === first.box.form &&
|
|
26
|
+
[0, 1].some((level) => run.every((box) => box.box.within[level] === next.box.within[level])) &&
|
|
27
|
+
Math.abs(centreY(next) - centreY(first)) <= first.rect.h / 2 &&
|
|
28
|
+
next.rect.x > last.rect.x &&
|
|
29
|
+
Math.max(...widths) <= Math.min(...widths) * 1.5);
|
|
30
|
+
};
|
|
31
|
+
// Runs of one-character boxes that follow one another among the listed fields; another field between them ends a run.
|
|
32
|
+
const runsOf = (actions) => {
|
|
33
|
+
const runs = [];
|
|
34
|
+
let run = [];
|
|
35
|
+
for (const action of actions) {
|
|
36
|
+
if (action.kind !== 'fill')
|
|
37
|
+
continue;
|
|
38
|
+
if (holdsOneCharacter(action) && run.length && joins(run, action)) {
|
|
39
|
+
run.push(action);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (run.length)
|
|
43
|
+
runs.push(run);
|
|
44
|
+
run = holdsOneCharacter(action) ? [action] : [];
|
|
45
|
+
}
|
|
46
|
+
if (run.length)
|
|
47
|
+
runs.push(run);
|
|
48
|
+
return runs.filter((boxes) => boxes.length >= MIN_BOXES && boxes.length <= MAX_BOXES);
|
|
49
|
+
};
|
|
50
|
+
// "Security code character 3" → "Security code character": a box's label without its place in the group.
|
|
51
|
+
const stemOf = (label) => label
|
|
52
|
+
.replace(/\d+/g, ' ')
|
|
53
|
+
.replace(/\bof\b/gi, ' ')
|
|
54
|
+
.replace(/^[^\p{L}]+|[^\p{L}]+$/gu, '')
|
|
55
|
+
.replace(/\s+/g, ' ');
|
|
56
|
+
const nameOf = (run) => {
|
|
57
|
+
if (run[0].box.name)
|
|
58
|
+
return run[0].box.name;
|
|
59
|
+
const stems = new Set(run.map((box) => stemOf(box.label)));
|
|
60
|
+
const [stem] = stems;
|
|
61
|
+
return stems.size === 1 && stem && !GENERIC_WORDS.test(stem) ? stem : FALLBACK_NAME;
|
|
62
|
+
};
|
|
63
|
+
const unionOf = (run) => {
|
|
64
|
+
const left = Math.min(...run.map((box) => box.rect.x));
|
|
65
|
+
const top = Math.min(...run.map((box) => box.rect.y));
|
|
66
|
+
const right = Math.max(...run.map((box) => box.rect.x + box.rect.w));
|
|
67
|
+
const bottom = Math.max(...run.map((box) => box.rect.y + box.rect.h));
|
|
68
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
69
|
+
};
|
|
70
|
+
const withoutFacts = ({ box: _box, ...action }) => action;
|
|
71
|
+
/**
|
|
72
|
+
* A code split into one-character boxes (4–8 `maxlength=1` boxes, or narrow numeric/one-time-code ones with no
|
|
73
|
+
* `maxlength`, following one another in one form and container, left to right on one row) becomes one `fill` target on
|
|
74
|
+
* its first box: `boxes` lists every box, `value` joins their values, `rect` covers them all, and the label is the
|
|
75
|
+
* group's name with the count. The other boxes and their `Open …` clicks are not listed. The reader's facts are removed
|
|
76
|
+
* from every action, and element ids are renumbered when anything was grouped.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* groupCodeBoxes(snapshot.actions) // [{ kind: 'fill', node: 11, label: 'Verification code (6 boxes)', boxes: [11, …, 16], … }, …]
|
|
80
|
+
*/
|
|
81
|
+
export const groupCodeBoxes = (actions) => {
|
|
82
|
+
const runs = runsOf(actions);
|
|
83
|
+
if (!runs.length)
|
|
84
|
+
return actions.some((action) => action.box) ? actions.map(withoutFacts) : actions;
|
|
85
|
+
const groupOf = new Map();
|
|
86
|
+
for (const run of runs) {
|
|
87
|
+
const group = {
|
|
88
|
+
first: run[0].node,
|
|
89
|
+
boxes: run.map((box) => box.node),
|
|
90
|
+
label: `${nameOf(run)} (${run.length} boxes)`,
|
|
91
|
+
value: run.map((box) => box.value ?? '').join(''),
|
|
92
|
+
rect: unionOf(run),
|
|
93
|
+
};
|
|
94
|
+
for (const node of group.boxes)
|
|
95
|
+
groupOf.set(node, group);
|
|
96
|
+
}
|
|
97
|
+
let next = 0;
|
|
98
|
+
return actions.flatMap((action) => {
|
|
99
|
+
const group = action.node === undefined ? undefined : groupOf.get(action.node);
|
|
100
|
+
if (group && action.node !== group.first)
|
|
101
|
+
return [];
|
|
102
|
+
const { label, value, boxes, rect } = group ?? {};
|
|
103
|
+
const listed = group ? { ...withoutFacts(action), label: action.kind === 'click' ? `Open ${label}` : label, value, boxes, rect } : withoutFacts(action);
|
|
104
|
+
return [action.node === undefined ? listed : { ...listed, id: `e${++next}` }];
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The characters a code puts into `count` boxes, one each: the text as given, or without its spaces and hyphens when
|
|
109
|
+
* only that fits ("739 214" into 6 boxes). Undefined when neither fits.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* codeCharacters('739-214', 6) // ['7', '3', '9', '2', '1', '4']
|
|
113
|
+
*/
|
|
114
|
+
export const codeCharacters = (text, count) => {
|
|
115
|
+
for (const form of [text, text.replace(/[\s-]/g, '')]) {
|
|
116
|
+
const characters = [...form];
|
|
117
|
+
if (characters.length === count)
|
|
118
|
+
return characters;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
};
|
package/dist/browser/frames.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { centerOf, contains } from '../jev/box.js';
|
|
2
2
|
import { CdpError } from './cdp.js';
|
|
3
|
+
import { groupCodeBoxes } from './code-boxes.js';
|
|
3
4
|
// jevway: every frame is read with the same reader (snapshot.js), each in an isolated world of its own, so ids, guards and
|
|
4
5
|
// the input counter live where the frame's elements live. The main frame keeps the main world, exactly as before frames.
|
|
5
6
|
/** Each frame owns a range of element ids this wide; the main frame is range 0, so its ids are what they always were. */
|
|
@@ -247,7 +248,7 @@ export class Frames {
|
|
|
247
248
|
const state = response.exceptionDetails ? null : response.result?.value;
|
|
248
249
|
if (!state)
|
|
249
250
|
return null;
|
|
250
|
-
const placed = placeFrameActions(state.actions, { w: state.w, h: state.h }, placement, frame.id);
|
|
251
|
+
const placed = placeFrameActions(groupCodeBoxes(state.actions), { w: state.w, h: state.h }, placement, frame.id);
|
|
251
252
|
// On-screen controls must be on top in every document around the frame: a banner or sheet over the frame hides them.
|
|
252
253
|
const shown = placed.filter((action) => contains(placement.region, centerOf(action.rect)));
|
|
253
254
|
const onTop = await this.#hits(levels, shown.map((action) => centerOf(action.rect)));
|
package/dist/browser/snapshot.js
CHANGED
|
@@ -332,6 +332,34 @@
|
|
|
332
332
|
if (['auto', 'scroll'].includes(getComputedStyle(p).overflowX) && p.scrollWidth > p.clientWidth) return row;
|
|
333
333
|
return null;
|
|
334
334
|
};
|
|
335
|
+
// jevway: a short text input may be one box of a code split into one-character boxes. The tab groups them from these
|
|
336
|
+
// facts (code-boxes.ts); only the group's name is read here, where the page is: an aria name or legend around the
|
|
337
|
+
// boxes, else a short line right before them. Container and form keys are unique within this read.
|
|
338
|
+
const containerKeys = new Map();
|
|
339
|
+
const keyOf = (n) => (n ? containerKeys.get(n) ?? containerKeys.set(n, containerKeys.size + 1).get(n) : 0);
|
|
340
|
+
const codeGroupName = (e) => {
|
|
341
|
+
for (let p = e.parentElement, up = 0; p && up < 3 && p !== document.body && p.tagName !== 'FORM'; p = p.parentElement, up++) {
|
|
342
|
+
const legend = p.tagName === 'FIELDSET' ? p.querySelector('legend') : null;
|
|
343
|
+
const own = p.getAttribute('aria-label') || (p.hasAttribute('aria-labelledby') ? name(p) : '') || legend?.innerText || '';
|
|
344
|
+
if (own.trim()) return cap(own.replace(/\s+/g, ' ').trim(), 80);
|
|
345
|
+
}
|
|
346
|
+
for (let p = e, up = 0; p && up < 3 && p !== document.body; p = p.parentElement, up++) {
|
|
347
|
+
const before = p.previousElementSibling;
|
|
348
|
+
if (!before || before.matches(interactive) || before.querySelector(interactive) || !visible(before)) continue;
|
|
349
|
+
const line = before.innerText.replace(/\s+/g, ' ').trim();
|
|
350
|
+
if (line) return line.length <= 80 ? line : '';
|
|
351
|
+
}
|
|
352
|
+
return '';
|
|
353
|
+
};
|
|
354
|
+
const codeBoxFacts = (e) => {
|
|
355
|
+
if (e.tagName !== 'INPUT') return null;
|
|
356
|
+
const max = e.maxLength;
|
|
357
|
+
if (!(max >= 1 && max <= 4) && !(max < 0 && e.getBoundingClientRect().width <= 64)) return null;
|
|
358
|
+
const parent = e.parentElement;
|
|
359
|
+
const within = [keyOf(parent), keyOf(parent?.parentElement)];
|
|
360
|
+
const facts = { max, mode: e.inputMode || '', otp: e.autocomplete === 'one-time-code', type: e.type, form: keyOf(e.form), within };
|
|
361
|
+
return { box: { ...facts, name: codeGroupName(e) } };
|
|
362
|
+
};
|
|
335
363
|
for (const e of candidates) {
|
|
336
364
|
const shown = eligible(e);
|
|
337
365
|
const hover = !shown && revealedByHover(e);
|
|
@@ -402,7 +430,7 @@
|
|
|
402
430
|
(rname === 'combobox' && (['INPUT', 'TEXTAREA'].includes(e.tagName) || e.isContentEditable)));
|
|
403
431
|
const value =
|
|
404
432
|
'value' in e ? String(e.value) : e.isContentEditable || rname === 'combobox' ? e.innerText.trim() : '';
|
|
405
|
-
actions.push({ ...base, kind: editable ? 'fill' : 'click', value });
|
|
433
|
+
actions.push({ ...base, kind: editable ? 'fill' : 'click', value, ...(editable && !hover && codeBoxFacts(e)) });
|
|
406
434
|
if (editable) actions.push({ ...base, kind: 'click', value, label: 'Open ' + base.label });
|
|
407
435
|
}
|
|
408
436
|
}
|
package/dist/browser/tab.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
import { CdpError } from './cdp.js';
|
|
6
|
+
import { codeCharacters, groupCodeBoxes } from './code-boxes.js';
|
|
6
7
|
import { DEVICES } from './devices.js';
|
|
7
8
|
import { TabDownloads } from './downloads.js';
|
|
8
9
|
import { Frames, idBaseOf, mergeFrames, whenAborted } from './frames.js';
|
|
@@ -453,7 +454,7 @@ export class Tab {
|
|
|
453
454
|
try {
|
|
454
455
|
const main = await this.#evaluate(`window.__jevWanted=${JSON.stringify(this.#wanted)};window.__jevFiles=${this.#files};${READ_STATE}`);
|
|
455
456
|
if (main) {
|
|
456
|
-
const state = await this.#withFrames(main);
|
|
457
|
+
const state = await this.#withFrames({ ...main, actions: groupCodeBoxes(main.actions) });
|
|
457
458
|
const named = placeholders ? { ...state, text: `Loading: ${placeholders} placeholder blocks on screen\n${state.text}` } : state;
|
|
458
459
|
return { ...named, fingerprint: fingerprint(named) };
|
|
459
460
|
}
|
|
@@ -522,8 +523,13 @@ export class Tab {
|
|
|
522
523
|
]);
|
|
523
524
|
return isDeepStrictEqual([main, current], [page.page_key, [observed.page_key, page.guards[String(action.node)]]]);
|
|
524
525
|
}
|
|
525
|
-
/**
|
|
526
|
+
/**
|
|
527
|
+
* Runs one observed action. A scroll resolves to how far it moved the page (see `#scroll`); typing into a split code's
|
|
528
|
+
* boxes, to whether they ended up holding the code (`kept`, absent when they can't be read).
|
|
529
|
+
*/
|
|
526
530
|
async act(action, page, text, { evenIfChanged = false } = {}) {
|
|
531
|
+
if (action.kind === 'fill' && action.boxes)
|
|
532
|
+
return this.#typeBoxes(action, page, text ?? '');
|
|
527
533
|
// Only a page-level action may skip the check, and only when the loop asks: Enter still needs its field focused (#pressEnter).
|
|
528
534
|
const skipCheck = evenIfChanged && PAGE_LEVEL_KINDS.includes(action.kind);
|
|
529
535
|
if (!skipCheck && !(await this.fresh(page, action)))
|
|
@@ -542,6 +548,39 @@ export class Tab {
|
|
|
542
548
|
this.#afterInput = action;
|
|
543
549
|
return outcome;
|
|
544
550
|
}
|
|
551
|
+
// jevway: a code split into one-character boxes is typed box by box through `act`, so every box gets the freshness
|
|
552
|
+
// check, the hit test, the delivered-press check and real key events. Typing changes the page, so each box after the
|
|
553
|
+
// first is checked against a fresh read, which also lets the page's own move to the next box settle. A box that
|
|
554
|
+
// already holds its character (the page moved or spread it) is left alone, so nothing is typed twice.
|
|
555
|
+
async #typeBoxes(group, page, text) {
|
|
556
|
+
const boxes = group.boxes;
|
|
557
|
+
const characters = codeCharacters(text, boxes.length);
|
|
558
|
+
if (!characters)
|
|
559
|
+
throw new Error(`The code has ${[...text].length} characters but "${group.label}" has ${boxes.length} boxes`);
|
|
560
|
+
if (!(await this.fresh(page, group)))
|
|
561
|
+
throw new StalePage('Page changed since this decision');
|
|
562
|
+
let current = page;
|
|
563
|
+
for (const [i, node] of boxes.entries()) {
|
|
564
|
+
if (i)
|
|
565
|
+
current = await this.observe();
|
|
566
|
+
const listed = current.actions.find((action) => action.kind === 'fill' && action.node === group.node && isDeepStrictEqual(action.boxes, boxes));
|
|
567
|
+
if (!listed)
|
|
568
|
+
throw new StalePage('The code boxes changed while typing');
|
|
569
|
+
const [held] = await this.#boxValues(group, [node]);
|
|
570
|
+
if (held === characters[i])
|
|
571
|
+
continue;
|
|
572
|
+
await this.act({ ...listed, node, boxes: undefined, label: `${listed.label}, box ${i + 1}` }, current, characters[i]);
|
|
573
|
+
}
|
|
574
|
+
const values = await this.#boxValues(group, boxes).catch(() => undefined);
|
|
575
|
+
return values ? { kept: values.join('') === characters.join('') } : {};
|
|
576
|
+
}
|
|
577
|
+
// What each box holds now, read in the group's frame; null for a box that is gone.
|
|
578
|
+
async #boxValues(group, nodes) {
|
|
579
|
+
const read = `(function jevBoxValues(ids) {
|
|
580
|
+
return ids.map((id) => { const e = window.__jevFast?.nodes.get(id); return e?.isConnected ? String(e.value ?? '') : null; });
|
|
581
|
+
})(${JSON.stringify(nodes)})`;
|
|
582
|
+
return (await this.#evaluateFor(this.#frameOf(group.node), read)) ?? [];
|
|
583
|
+
}
|
|
545
584
|
// jevway: the wheel goes where the reader found the pane that scrolls (or the fixed point on a page whose document
|
|
546
585
|
// scrolls), and the offsets before and after say whether it moved anything. The wheel lands asynchronously, so the
|
|
547
586
|
// offsets are polled until they stop moving: 500 ms with no movement means nothing moved, 1.5 s at most.
|
package/dist/task/run-task.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tmpdir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { findBotWall } from '../browser/bot-wall.js';
|
|
4
|
+
import { codeCharacters } from '../browser/code-boxes.js';
|
|
4
5
|
import { PAGE_LEVEL_KINDS, StalePage, TargetUnreachable } from '../browser/tab.js';
|
|
5
6
|
import { showsTarget } from '../browser/words.js';
|
|
6
7
|
import { buildActionSpace } from '../jev/action-space.js';
|
|
@@ -8,7 +9,7 @@ import { centerOf, contains } from '../jev/box.js';
|
|
|
8
9
|
import { decide, evidenceRequest, foldedFavourite, overflowFolds, readEvidence } from '../jev/decide.js';
|
|
9
10
|
import { estimateCostUsd } from '../jev/model.js';
|
|
10
11
|
import { ANY_HOST, narrowHosts, outsideHosts } from './hosts.js';
|
|
11
|
-
import { scrubberFor } from '../jev/secrets.js';
|
|
12
|
+
import { scrubberFor, secretMarker } from '../jev/secrets.js';
|
|
12
13
|
import { describeFailure, runChecks } from './checks.js';
|
|
13
14
|
import { deepLink, quoteSpans } from './evidence.js';
|
|
14
15
|
import { writeCall } from '../output/run-folder.js';
|
|
@@ -199,6 +200,13 @@ export class BrowserTask {
|
|
|
199
200
|
#toggled = new Map();
|
|
200
201
|
/** Nodes of fields the model chose with no supplied value to type: each is refused once before the caller is asked. */
|
|
201
202
|
#refusedForValue = new Set();
|
|
203
|
+
/** First-box nodes of split-code groups a value of the wrong length was chosen for: each is fed back once before the caller is asked. */
|
|
204
|
+
#codeLengthNoted = new Set();
|
|
205
|
+
/**
|
|
206
|
+
* Boxes of a split code a secret was typed into, with the secret's key and their document (ids restart in a new one):
|
|
207
|
+
* their values read as its marker from then on.
|
|
208
|
+
*/
|
|
209
|
+
#secretBoxes = new Map();
|
|
202
210
|
/** The caller's authenticator secrets by key: only `decide` (as a marker) and the typing see them. */
|
|
203
211
|
#totp = new Map();
|
|
204
212
|
/** Secrets that are not supplied values, by key: each authenticator secret as given and as base32, every code typed. Kept for the session. */
|
|
@@ -274,12 +282,20 @@ export class BrowserTask {
|
|
|
274
282
|
title: scrub(page.title),
|
|
275
283
|
text: scrub(page.text),
|
|
276
284
|
...(page.passwordFields && { passwordFields: page.passwordFields.map((field) => ({ ...field, name: scrub(field.name) })) }),
|
|
277
|
-
actions: page.actions.map((action) =>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
actions: page.actions.map((action) => {
|
|
286
|
+
// A box of a split secret code may hold part of it, which no value match finds: it shows the key's marker.
|
|
287
|
+
const document = documentOf(page, action);
|
|
288
|
+
const boxKey = [action.node, ...(action.boxes ?? [])]
|
|
289
|
+
.map((node) => this.#secretBoxes.get(node))
|
|
290
|
+
.find((noted) => noted?.document === document)?.key;
|
|
291
|
+
return {
|
|
292
|
+
...action,
|
|
293
|
+
label: scrub(action.label),
|
|
294
|
+
...(action.value !== undefined && action.kind !== 'select' && { value: scrub(action.value) }),
|
|
295
|
+
...(action.current_value !== undefined && { current_value: scrub(action.current_value) }),
|
|
296
|
+
...(boxKey && action.value && { value: secretMarker(boxKey) }),
|
|
297
|
+
};
|
|
298
|
+
}),
|
|
283
299
|
};
|
|
284
300
|
}
|
|
285
301
|
/** Reads the session's tab with no model call and no input, its secret values already masked: what `jevway_look` shows. With `pdf`, the page as a PDF too. */
|
|
@@ -913,7 +929,41 @@ export class BrowserTask {
|
|
|
913
929
|
}
|
|
914
930
|
const action = decision.action;
|
|
915
931
|
const text = decision.valueKey ? await this.#textFor(decision.valueKey, values) : undefined;
|
|
932
|
+
// A CLICK_AT carries no action.
|
|
933
|
+
if (action?.boxes && decision.valueKey && text !== undefined) {
|
|
934
|
+
const characters = codeCharacters(text, action.boxes.length);
|
|
935
|
+
if (!characters) {
|
|
936
|
+
// A value that doesn't fit the boxes is a wrong turn first, and only the second time a question for the caller.
|
|
937
|
+
record(false);
|
|
938
|
+
const [count, boxes] = [[...text].length, action.boxes.length];
|
|
939
|
+
if (this.#codeLengthNoted.has(action.node)) {
|
|
940
|
+
// A hand-back decided on a page that has since changed is stale: read it again instead.
|
|
941
|
+
if (!(await this.#tab.fresh(page))) {
|
|
942
|
+
page = await this.#observe();
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
return stop('needs_input', `"${action.label}" has ${boxes} boxes but the "${decision.valueKey}" value has ${count} characters; supply it as ${boxes} characters`, decision);
|
|
946
|
+
}
|
|
947
|
+
this.#codeLengthNoted.add(action.node);
|
|
948
|
+
this.#history.push({
|
|
949
|
+
operation: decision.operation,
|
|
950
|
+
element: action.label,
|
|
951
|
+
not_executed: `the "${decision.valueKey}" value has ${count} characters but this field has ${boxes} boxes; type nothing here — choose another value or step, or BLOCKED if the goal needs a value you were not given`,
|
|
952
|
+
confidence: round(decision.confidence),
|
|
953
|
+
});
|
|
954
|
+
page = await this.#observe();
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
// Masked before it is typed, so the page read right after typing already shows the marker.
|
|
958
|
+
if (this.#options.secretKeys.includes(decision.valueKey)) {
|
|
959
|
+
for (const node of action.boxes)
|
|
960
|
+
this.#secretBoxes.set(node, { key: decision.valueKey, document: documentOf(page, action) });
|
|
961
|
+
if (characters.join('') !== text)
|
|
962
|
+
this.#moreSecrets.push([decision.valueKey, characters.join('')]);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
916
965
|
let element;
|
|
966
|
+
let kept;
|
|
917
967
|
// jevway: the dialog a confirmed click opens is the same action asking again; any other dialog still gets the answer that changes nothing.
|
|
918
968
|
if (isRiskyClick(decision) && this.#confirmedCommit)
|
|
919
969
|
this.#tab.armConfirm([this.#confirmTarget ?? '', bareLabel(decision.action?.label ?? '')]);
|
|
@@ -945,6 +995,7 @@ export class BrowserTask {
|
|
|
945
995
|
const outcome = action.kind === 'upload'
|
|
946
996
|
? await this.#tab.upload(action, page, this.#options.files[decision.fileKey])
|
|
947
997
|
: await this.#tab.act(action, page, text, forced ? { evenIfChanged: true } : undefined);
|
|
998
|
+
kept = outcome?.kept;
|
|
948
999
|
// A scroll that moved nothing says so, or Jev keeps asking for it.
|
|
949
1000
|
element = outcome?.scrolled === 0 ? `${action.label} (nothing moved: there is no more to scroll this way)` : action.label;
|
|
950
1001
|
}
|
|
@@ -1069,7 +1120,8 @@ export class BrowserTask {
|
|
|
1069
1120
|
this.#history.push({
|
|
1070
1121
|
...ran,
|
|
1071
1122
|
// A field that is gone (navigated, submitted) can't say; one still on the page must show what was typed.
|
|
1072
|
-
|
|
1123
|
+
// Split code boxes report what they hold right after typing; any other field, on the page read after it.
|
|
1124
|
+
...(kept !== undefined ? { value_kept: kept } : field && { value_kept: field.value === this.#scrub(text) }),
|
|
1073
1125
|
page_changed: page.fingerprint !== before,
|
|
1074
1126
|
confidence,
|
|
1075
1127
|
});
|
|
@@ -1775,6 +1827,8 @@ const decoded = (url) => {
|
|
|
1775
1827
|
return url;
|
|
1776
1828
|
}
|
|
1777
1829
|
};
|
|
1830
|
+
// The document an action's node belongs to (its frame's, or the page's): its start time, as the reader's keys hold it.
|
|
1831
|
+
const documentOf = (page, action) => JSON.stringify(action.frame ? page.frames?.find((frame) => frame.id === action.frame)?.identity : page.page_key?.[0]);
|
|
1778
1832
|
const bareLabel = (label) => label.replace(/ \(appears on hover\)$/, '');
|
|
1779
1833
|
/** Up to 3 labels, in list order and cut at 40 characters, of the listed controls inside the layer covering a target; none when nothing usable names it. */
|
|
1780
1834
|
const coveringLabels = (coverNodes, page) => page.actions
|