jevway 0.0.0-stage → 0.1.2

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.
Files changed (43) hide show
  1. package/INSTALL-FOR-AGENTS.md +138 -0
  2. package/LICENSE +11 -0
  3. package/LICENSE.jev-ultrafast +21 -0
  4. package/README.md +218 -2
  5. package/dist/browser/bot-wall.js +105 -0
  6. package/dist/browser/cdp.js +112 -0
  7. package/dist/browser/chrome.js +147 -0
  8. package/dist/browser/devices.js +20 -0
  9. package/dist/browser/frames.js +516 -0
  10. package/dist/browser/snapshot.js +611 -0
  11. package/dist/browser/tab.js +938 -0
  12. package/dist/browser/words.js +51 -0
  13. package/dist/cli.js +112 -0
  14. package/dist/jev/action-space.js +48 -0
  15. package/dist/jev/box.js +58 -0
  16. package/dist/jev/decide.js +405 -0
  17. package/dist/jev/instructions.js +67 -0
  18. package/dist/jev/model.js +189 -0
  19. package/dist/jev/replay.js +213 -0
  20. package/dist/jev/secrets.js +60 -0
  21. package/dist/journeys/diagnostics.js +85 -0
  22. package/dist/journeys/journey-doc.js +50 -0
  23. package/dist/journeys/journey-session.js +503 -0
  24. package/dist/journeys/pick-next-move.js +59 -0
  25. package/dist/journeys/render-viewer.js +153 -0
  26. package/dist/journeys/screen-boxes.js +49 -0
  27. package/dist/journeys/screen-judgments.js +149 -0
  28. package/dist/mcp.js +361 -0
  29. package/dist/setup/doctor-command.js +81 -0
  30. package/dist/setup/doctor.js +163 -0
  31. package/dist/setup/env-file.js +50 -0
  32. package/dist/task/checks.js +156 -0
  33. package/dist/task/evidence.js +96 -0
  34. package/dist/task/hosts.js +91 -0
  35. package/dist/task/look.js +17 -0
  36. package/dist/task/recording-page.js +310 -0
  37. package/dist/task/recording.js +232 -0
  38. package/dist/task/run-limit.js +66 -0
  39. package/dist/task/run-task.js +1607 -0
  40. package/dist/task/start-task.js +91 -0
  41. package/dist/task/upload-files.js +43 -0
  42. package/package.json +80 -4
  43. package/skill/jevway/SKILL.md +73 -0
@@ -0,0 +1,938 @@
1
+ var _a;
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { CdpError } from './cdp.js';
6
+ import { DEVICES } from './devices.js';
7
+ import { Frames, idBaseOf, mergeFrames, whenAborted } from './frames.js';
8
+ import { messageNames } from './words.js';
9
+ // Atomic DOM read from browser-use/jev-ultrafast (MIT): visible text + indexed controls + freshness guards.
10
+ const READ_STATE = readFileSync(new URL('./snapshot.js', import.meta.url), 'utf8');
11
+ const MARKER = `(() => { const state=${READ_STATE}; return state?.marker ?? null; })()`;
12
+ // How long observe() keeps retrying a page that is still changing, and what a call that got no reply at all looks like.
13
+ const OBSERVE_DEADLINE_MS = 2_000;
14
+ const TIMED_OUT = /timed out after/;
15
+ // jevway: a page drawn as pulsing placeholders has not loaded what the user came for, so reading it now invites a click on
16
+ // whatever chrome is listed. The wait is capped, and a page that keeps pulsing is named as loading.
17
+ const PLACEHOLDERS_MIN = 3;
18
+ const PLACEHOLDERS_WAIT_MS = 1_500;
19
+ const PLACEHOLDERS_POLL_MS = 100;
20
+ // On-screen elements with no text of their own that run an endless animation: skeleton blocks and spinners, 0 once loaded.
21
+ const COUNT_PLACEHOLDERS = `(() => {
22
+ const targets = new Set(document.getAnimations()
23
+ .filter((a) => a.playState === 'running' && a.effect?.getComputedTiming().endTime === Infinity)
24
+ .map((a) => a.effect?.target));
25
+ let count = 0;
26
+ for (const el of targets) {
27
+ if (!el?.getBoundingClientRect || (el.textContent || '').trim()) continue;
28
+ const r = el.getBoundingClientRect();
29
+ if (r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth) count++;
30
+ }
31
+ return count;
32
+ })()`;
33
+ /** Actions that target no element and are reversible: the loop may run them on a page that changed since the decision. */
34
+ export const PAGE_LEVEL_KINDS = ['wait', 'escape', 'scroll', 'enter'];
35
+ /** The page moved on since it was observed; observe again and re-decide. Never a reason to guess. */
36
+ export class StalePage extends Error {
37
+ }
38
+ /** The page is as observed, but the chosen target can't be reached: covered, clipped, or gone from its spot. */
39
+ export class TargetUnreachable extends StalePage {
40
+ /** True when what sits on top is a popup layer (suggestions, a menu, a popover) that Escape can close. */
41
+ byPopup;
42
+ /** Observed node ids inside the fixed, sticky or modal layer on top of the target, so the run can name its controls. */
43
+ coverNodes;
44
+ constructor(message, byPopup = false, coverNodes = []) {
45
+ super(message);
46
+ this.byPopup = byPopup;
47
+ this.coverNodes = coverNodes;
48
+ }
49
+ }
50
+ /** The browser took the press but the page never received it, so nothing was clicked. Not a StalePage: it is never retried blind. */
51
+ export class InputNotDelivered extends Error {
52
+ }
53
+ /**
54
+ * One browser tab driven through observed actions only.
55
+ * Model output selects an observed action id; it never becomes a selector, coordinate or script.
56
+ *
57
+ * @example
58
+ * const tab = await Tab.open(cdp, 'https://en.wikipedia.org');
59
+ * const page = await tab.observe();
60
+ * await tab.act(page.actions[0], page);
61
+ */
62
+ export class Tab {
63
+ #afterInput;
64
+ #wanted = [];
65
+ /** The caller declared files to upload: file inputs are listed, and a native file picker is intercepted. */
66
+ #files = false;
67
+ #touch = false;
68
+ #dialogs = [];
69
+ /** The phrases the next confirm dialog must name to get OK; set only around a click the caller confirmed. */
70
+ #confirmArm;
71
+ /** The device this tab emulates, for a tab it opens. */
72
+ #device;
73
+ /** Page tabs this tab opened (target=_blank, window.open) that the run has not taken yet, oldest first. */
74
+ #opened = [];
75
+ #closed = false;
76
+ #unsubscribe = [];
77
+ /** The running call's stop signal: the waits after an input end early on it (see `stopSignal`). */
78
+ #stop;
79
+ cdp;
80
+ targetId;
81
+ sessionId;
82
+ #frames;
83
+ constructor(cdp, targetId, sessionId) {
84
+ this.cdp = cdp;
85
+ this.targetId = targetId;
86
+ this.sessionId = sessionId;
87
+ this.#frames = new Frames(cdp, sessionId, INPUT_COUNTER);
88
+ }
89
+ /**
90
+ * Opens a background tab so the user's visible tab stays put. `newWindow` keeps the page rendering
91
+ * unthrottled in the user's own Chrome, which screenshots need.
92
+ */
93
+ static async open(cdp, url, { device = DEVICES.desktop, newWindow = false } = {}) {
94
+ const { targetId } = await cdp.send('Target.createTarget', {
95
+ url: 'about:blank',
96
+ background: true,
97
+ ...(newWindow && { newWindow: true, width: device.width + 40, height: device.height + 120 }),
98
+ });
99
+ const tab = await _a.attach(cdp, targetId);
100
+ await tab.emulate(device);
101
+ await tab.navigate(url);
102
+ return tab;
103
+ }
104
+ /** Viewport, pixel ratio, UA and touch. Mobile devices tap with touch events instead of mouse clicks. */
105
+ async emulate(device) {
106
+ const { width, height, deviceScaleFactor, mobile, userAgent } = device;
107
+ await this.#call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor, mobile });
108
+ await this.#call('Emulation.setTouchEmulationEnabled', { enabled: mobile, maxTouchPoints: mobile ? 5 : 1 });
109
+ if (userAgent)
110
+ await this.#call('Emulation.setUserAgentOverride', { userAgent });
111
+ this.#touch = mobile;
112
+ this.#device = device;
113
+ this.#frames.touch = mobile;
114
+ }
115
+ /** Viewport screenshot as base64. `scale` < 1 makes a light preview for the agent. */
116
+ async screenshot({ format = 'png', quality, scale, } = {}) {
117
+ const clip = scale ? await this.#viewportClip(scale) : undefined;
118
+ const { data } = await this.#call('Page.captureScreenshot', {
119
+ format,
120
+ ...(quality && { quality }),
121
+ ...(clip && { clip }),
122
+ });
123
+ return data;
124
+ }
125
+ /**
126
+ * Viewport JPEG with each mark drawn as a numbered box, so a vision model can tie what it sees to an
127
+ * element index. The overlay is removed before this returns, so it never meets a freshness check or input.
128
+ * `sharp` is for people (step recordings): native pixel ratio at high quality, never sent to a model.
129
+ * The model image is sized from the viewport captured now (a page can zoom out to fit after it was observed, so
130
+ * `viewport` is only the observed size) and checked after capture: it never leaves with a side over its limit.
131
+ *
132
+ * @example
133
+ * const jpeg = await tab.markedScreenshot(buildActionSpace(page.actions).marks, page);
134
+ */
135
+ async markedScreenshot(marks, viewport, { sharp = false } = {}) {
136
+ const pixelRatio = Number(await this.#evaluate('devicePixelRatio')) || 1;
137
+ await this.#evaluate(`(${DRAW_MARKS})(${JSON.stringify(marks)})`);
138
+ try {
139
+ // Rendered at 2× CSS pixels whatever the screen: Chrome re-rasterises at the clip scale, so text stays crisp.
140
+ if (sharp)
141
+ return await this.screenshot({ format: 'jpeg', quality: 90, scale: 2 / pixelRatio });
142
+ // Output pixels are CSS pixels × scale × pixel ratio. Models read about a megapixel, and Jev rejects any side
143
+ // over 1568 px (a phone at 2× is 786×1704 unscaled), so stay within 1024 wide and 1536 tall.
144
+ const { clientWidth, clientHeight } = await this.#cssViewport();
145
+ const scale = Math.min(1, MODEL_IMAGE.width / (clientWidth * pixelRatio), MODEL_IMAGE.height / (clientHeight * pixelRatio));
146
+ const jpeg = await this.screenshot({ format: 'jpeg', quality: 60, scale });
147
+ const size = jpegSize(jpeg);
148
+ const overshoot = size ? Math.max(size.width / MODEL_IMAGE.width, size.height / MODEL_IMAGE.height) : 1;
149
+ if (overshoot <= 1)
150
+ return jpeg;
151
+ // The page resized between measuring and capturing: shrink by the overshoot and capture once more.
152
+ return await this.screenshot({ format: 'jpeg', quality: 60, scale: (scale / overshoot) * 0.99 });
153
+ }
154
+ finally {
155
+ await this.#evaluate(`document.getElementById('${MARKS_ID}')?.remove()`).catch(() => undefined);
156
+ }
157
+ }
158
+ /**
159
+ * Closes an open popup (suggestions, a menu) that covers the page: Escape, then leave the focused field and press
160
+ * outside. Fixed keys and events only, never a target from the model, so it needs no freshness check.
161
+ */
162
+ async dismissPopup() {
163
+ // Escape goes to whatever has focus; focus is never moved first, since focusing a search bar re-opens its list.
164
+ const key = { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 };
165
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyDown', ...key });
166
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyUp', ...key });
167
+ // Lists that ignore Escape close on blur or an outside press: a synthetic press on the page body hits no control.
168
+ await this.#evaluate(PRESS_OUTSIDE).catch(() => undefined);
169
+ this.#afterInput = undefined;
170
+ }
171
+ // A fixed Enter key into the field just typed into, only while that exact field still has focus.
172
+ async #pressEnter(action) {
173
+ // In a frame, the frame itself must have the page's focus too, or the key would go elsewhere.
174
+ const frame = this.#frameOf(action.node);
175
+ const focused = await this.#evaluateFor(frame, `(() => {
176
+ const e = window.__jevFast?.nodes.get(${Number(action.node)});
177
+ let active = document.activeElement;
178
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
179
+ return Boolean(e) && active === e${frame ? ' && document.hasFocus()' : ''};
180
+ })()`);
181
+ if (!focused)
182
+ throw new StalePage('The typed field lost focus');
183
+ const key = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
184
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyDown', ...key, text: '\r' });
185
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyUp', ...key });
186
+ }
187
+ /** Raw protocol access for diagnostics (console, network) scoped to this tab. */
188
+ send(method, params = {}) {
189
+ return this.#call(method, params);
190
+ }
191
+ onEvent(listener) {
192
+ return this.cdp.onEvent((method, params, sessionId) => {
193
+ if (sessionId === this.sessionId)
194
+ listener(method, params, sessionId);
195
+ });
196
+ }
197
+ async evaluate(expression) {
198
+ return this.#evaluate(expression);
199
+ }
200
+ async #cssViewport() {
201
+ const { cssVisualViewport } = await this.#call('Page.getLayoutMetrics');
202
+ return cssVisualViewport;
203
+ }
204
+ async #viewportClip(scale) {
205
+ const { pageX, pageY, clientWidth, clientHeight } = await this.#cssViewport();
206
+ return { x: pageX, y: pageY, width: clientWidth, height: clientHeight, scale };
207
+ }
208
+ /** Reuses an existing tab whose URL contains `urlPart`, e.g. the page chrome-devtools is inspecting. */
209
+ static async find(cdp, urlPart) {
210
+ const { targetInfos } = await cdp.send('Target.getTargets');
211
+ const target = targetInfos.find((info) => info.type === 'page' && info.url.includes(urlPart));
212
+ if (!target)
213
+ throw new Error(`No open tab URL contains "${urlPart}"`);
214
+ return _a.attach(cdp, target.targetId);
215
+ }
216
+ static async attach(cdp, targetId) {
217
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
218
+ const tab = new _a(cdp, targetId, sessionId);
219
+ // Keep rAF, menus and autocomplete rendering in a background tab.
220
+ await tab.#call('Emulation.setFocusEmulationEnabled', { enabled: true });
221
+ // Registered ahead of the page's own scripts, so its listeners cannot hide a press from ours; the open document gets it
222
+ // too. Chrome only runs a new-document script for a session with Page enabled.
223
+ await tab.#call('Page.enable');
224
+ await tab.#call('Page.addScriptToEvaluateOnNewDocument', { source: INPUT_COUNTER });
225
+ await tab.#call('Runtime.evaluate', { expression: INPUT_COUNTER }).catch(() => undefined);
226
+ // Every frame gets the same counter in its own world, and out-of-process frames are attached as they appear.
227
+ await tab.#frames.watch();
228
+ tab.#unsubscribe.push(tab.onEvent((method, params) => {
229
+ if (method === 'Page.javascriptDialogOpening')
230
+ tab.#answerDialog(params);
231
+ }));
232
+ // jevway: a tab this tab opens is noted by its opener id; discovery only reports targets, so a tab anyone else
233
+ // opened (the user's own, in their Chrome) is never attached, read or changed.
234
+ tab.#unsubscribe.push(cdp.onEvent((method, params, sessionId) => {
235
+ const info = params?.targetInfo;
236
+ if (method === 'Target.targetCreated' && info?.type === 'page' && info.openerId === targetId)
237
+ tab.#opened.push(info.targetId);
238
+ if (method === 'Target.targetDestroyed' && params?.targetId === targetId)
239
+ tab.#closed = true;
240
+ if (method === 'Target.detachedFromTarget' && params?.sessionId === tab.sessionId && !sessionId)
241
+ tab.#closed = true;
242
+ }));
243
+ await cdp.send('Target.setDiscoverTargets', { discover: true }).catch(() => undefined);
244
+ return tab;
245
+ }
246
+ /** True once this tab's target was closed or detached: it can't be read again. */
247
+ get closed() {
248
+ return this.#closed;
249
+ }
250
+ /** The tabs this tab opened since the last call, oldest first (target ids). */
251
+ takeOpened() {
252
+ return this.#opened.splice(0);
253
+ }
254
+ /**
255
+ * Drives a tab this tab opened with the same setup (`Tab.attach`), plus this tab's device, wanted values and
256
+ * confirmation arm, once its first real document has loaded. The input counter is evaluated on that document again:
257
+ * a popup that loaded before it was attached never got it from the new-document script.
258
+ *
259
+ * @example
260
+ * const [opened] = tab.takeOpened();
261
+ * if (opened) tab = await tab.follow(opened);
262
+ */
263
+ /**
264
+ * Lends the running call's stop signal (caller cancel, time budget) to this tab's waits: a followed tab's address
265
+ * and load, a scroll's settle poll and frame reads end early on it. Inputs never take it, so one under way finishes.
266
+ *
267
+ * @example
268
+ * tab.stopSignal = limit.signal; // while a call runs; undefined after
269
+ */
270
+ set stopSignal(signal) {
271
+ this.#stop = signal;
272
+ }
273
+ async follow(targetId) {
274
+ // A dialog the tab raised before it was followed freezes it, and Page.enable never re-sends it (probed): rather than
275
+ // wait out every 30 s protocol timeout, the follow fails as a browser error, which ends the run `blocked`.
276
+ let timer;
277
+ const frozen = new Promise((_, reject) => {
278
+ timer = setTimeout(() => reject(new CdpError(`The new tab did not answer within ${FOLLOW_MS / 1000} s: it may be showing a dialog it raised before jevway could answer it`)), FOLLOW_MS);
279
+ });
280
+ let next;
281
+ try {
282
+ next = await Promise.race([this.#setUpFollowed(targetId), frozen]);
283
+ }
284
+ finally {
285
+ clearTimeout(timer);
286
+ }
287
+ // Set up, its dialogs are answered from here on. A popup is created on about:blank and then navigates: wait for its URL, then for it to load.
288
+ const stop = whenAborted(this.#stop);
289
+ try {
290
+ const begun = Date.now();
291
+ // A stop answers null, which ends the wait as if the address had come.
292
+ const address = () => Promise.race([next.#evaluate('location.href').catch(() => 'about:blank'), stop.promise]);
293
+ while (Date.now() - begun < POPUP_URL_MS && (await address()) === 'about:blank')
294
+ await Promise.race([sleep(50), stop.promise]);
295
+ }
296
+ finally {
297
+ stop.release();
298
+ }
299
+ await next.#waitForLoad(Date.now() + 15_000);
300
+ await next.#evaluate(INPUT_COUNTER).catch(() => undefined);
301
+ return next;
302
+ }
303
+ // The protocol calls that a dialog raised before the follow would freeze: attach (with its dialog answer), device, values.
304
+ async #setUpFollowed(targetId) {
305
+ const next = await _a.attach(this.cdp, targetId);
306
+ if (this.#device)
307
+ await next.emulate(this.#device);
308
+ next.want([], { files: this.#files });
309
+ next.#wanted = this.#wanted;
310
+ next.#confirmArm = this.#confirmArm;
311
+ next.#stop = this.#stop;
312
+ return next;
313
+ }
314
+ // jevway: an open dialog freezes the page for every protocol call, so it is answered at once, with the choice that
315
+ // changes nothing: OK for an alert (its only button), Cancel for confirm and prompt, Stay for beforeunload. The one
316
+ // exception is the confirm dialog a confirmed click opens, when its message names the confirmed target or the control:
317
+ // that is the same action asking again. Any other dialog, including one the same click opens about something else, is declined.
318
+ #answerDialog({ type, message }) {
319
+ const armed = type === 'confirm' ? this.#confirmArm : undefined;
320
+ // The arm covers the first confirm after it, named or not.
321
+ if (type === 'confirm')
322
+ this.#confirmArm = undefined;
323
+ const accepted = type === 'alert' || (armed !== undefined && messageNames(message ?? '', armed));
324
+ this.#dialogs.push({ type, message: message ?? '', accepted });
325
+ void this.#call('Page.handleJavaScriptDialog', { accept: accepted }).catch(() => undefined);
326
+ }
327
+ /**
328
+ * One shot, for a click the caller confirmed: the next confirm dialog gets OK if its message names one of `phrases`
329
+ * (the confirmed target, the clicked control). Prompts and leave-the-page guards are never accepted.
330
+ */
331
+ armConfirm(phrases) {
332
+ this.#confirmArm = phrases;
333
+ }
334
+ /** Takes the arm back; the run does so when the step that armed it is over. */
335
+ disarmConfirm() {
336
+ this.#confirmArm = undefined;
337
+ }
338
+ /** The dialogs the page opened (and this tab answered) since the last call, oldest first. */
339
+ takeDialogs() {
340
+ return this.#dialogs.splice(0);
341
+ }
342
+ async navigate(url) {
343
+ await this.#call('Page.navigate', { url });
344
+ await this.#waitForLoad(Date.now() + 15_000);
345
+ }
346
+ async #waitForLoad(deadline) {
347
+ const stop = whenAborted(this.#stop);
348
+ try {
349
+ while (Date.now() < deadline && !this.#stop?.aborted) {
350
+ // A frozen renderer never answers: the wait ends at its deadline (or a stop), not after one more 30 s protocol call.
351
+ const state = await Promise.race([
352
+ this.#evaluate('document.readyState').catch(() => null),
353
+ sleep(deadline - Date.now()).then(() => null),
354
+ stop.promise,
355
+ ]);
356
+ if (state === 'complete')
357
+ return;
358
+ await Promise.race([sleep(20), stop.promise]);
359
+ }
360
+ }
361
+ finally {
362
+ stop.release();
363
+ }
364
+ }
365
+ /**
366
+ * Texts the caller supplied (task values). An off-screen link, button or row containing one is still listed, since
367
+ * it is likely the target; clicking scrolls it into view.
368
+ *
369
+ * @example
370
+ * tab.want(['u-Gy8s1dJcpm18C2qmaNcN8']); // the user link below the fold is listed on the next observe()
371
+ */
372
+ want(texts, { files = false } = {}) {
373
+ this.#wanted = texts.map((t) => t.trim().toLowerCase()).filter((t) => t.length >= 3);
374
+ // jevway: with files declared, a click on a styled "Upload" button must never open an OS dialog in the user's
375
+ // Chrome: the picker is intercepted (and left unanswered); UPLOAD sets the file on the field instead.
376
+ if (files && !this.#files)
377
+ void this.#call('Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => undefined);
378
+ this.#files = files;
379
+ }
380
+ /**
381
+ * Sets a declared file on an observed file input (hidden ones too), after the same element-level freshness check
382
+ * as a click. The path comes from the caller's `files`, never from the model or `values`.
383
+ *
384
+ * @example
385
+ * await tab.upload(page.actions.find((a) => a.kind === 'upload')!, page, '/Users/ada/me.png');
386
+ */
387
+ async upload(action, page, path) {
388
+ // The file is set through the page's own session; a frame's node lives in another context, so it is refused by name.
389
+ if (action.node !== undefined && idBaseOf(action.node)) {
390
+ throw new TargetUnreachable(`"${action.label}" is inside a frame, and jevway uploads only to file fields in the page itself`);
391
+ }
392
+ if (!(await this.fresh(page, action)))
393
+ throw new StalePage('Page changed since this decision');
394
+ const { result } = await this.#call('Runtime.evaluate', {
395
+ expression: `(() => { const e = window.__jevFast?.nodes.get(${Number(action.node)}); return e?.isConnected && e.matches('input[type=file]:not(:disabled)') ? e : null; })()`,
396
+ });
397
+ if (!result?.objectId)
398
+ throw new TargetUnreachable('The file field is gone or disabled');
399
+ await this.#call('DOM.setFileInputFiles', { files: [path], objectId: result.objectId });
400
+ this.#afterInput = action;
401
+ }
402
+ async observe() {
403
+ if (this.#closed)
404
+ throw new StalePage('The tab was closed');
405
+ const begun = Date.now();
406
+ await this.#settleAfterInput();
407
+ const placeholders = await this.#waitOutPlaceholders(begun);
408
+ const started = Date.now();
409
+ for (let attempt = 0; attempt < 25 && Date.now() - started < OBSERVE_DEADLINE_MS; attempt++) {
410
+ try {
411
+ const main = await this.#evaluate(`window.__jevWanted=${JSON.stringify(this.#wanted)};window.__jevFiles=${this.#files};${READ_STATE}`);
412
+ if (main) {
413
+ const state = await this.#withFrames(main);
414
+ const named = placeholders ? { ...state, text: `Loading: ${placeholders} placeholder blocks on screen\n${state.text}` } : state;
415
+ return { ...named, fingerprint: fingerprint(named) };
416
+ }
417
+ }
418
+ catch (error) {
419
+ if (!(error instanceof StalePage))
420
+ throw error;
421
+ }
422
+ await sleep(40);
423
+ }
424
+ throw new StalePage(`Page did not settle within ${((Date.now() - started) / 1000).toFixed(1)}s`);
425
+ }
426
+ // The main frame's state with every readable frame joined in (ids, text, password fields); unchanged on a page without them.
427
+ async #withFrames(main) {
428
+ const frames = await this.#frames.read({ w: main.w, h: main.h }, READ_STATE, this.#wanted, this.#stop).catch(() => []);
429
+ return frames.length ? { ...main, ...mergeFrames(main, frames) } : main;
430
+ }
431
+ // Polls the placeholder count from `since` until fewer than PLACEHOLDERS_MIN pulse (returns 0) or the cap runs out
432
+ // (returns how many still do, for the loading line).
433
+ async #waitOutPlaceholders(since) {
434
+ for (;;) {
435
+ const count = await this.#countPlaceholders();
436
+ if (count < PLACEHOLDERS_MIN)
437
+ return 0;
438
+ const left = PLACEHOLDERS_WAIT_MS - (Date.now() - since);
439
+ if (left <= 0)
440
+ return count;
441
+ await sleep(Math.min(PLACEHOLDERS_POLL_MS, left));
442
+ }
443
+ }
444
+ async #countPlaceholders() {
445
+ try {
446
+ const main = Number(await this.#evaluate(COUNT_PLACEHOLDERS)) || 0;
447
+ return main + (await this.#frames.countPlaceholders(COUNT_PLACEHOLDERS));
448
+ }
449
+ catch (error) {
450
+ // A page mid-navigation is read by the retry loop; a call that timed out stays a browser error.
451
+ if (error instanceof StalePage)
452
+ return 0;
453
+ throw error;
454
+ }
455
+ }
456
+ /** Clicks/selects compare the target's own guard; everything else compares the whole-page marker. */
457
+ async fresh(page, action) {
458
+ // Element-level for targeted input: busy pages (rotating banners) change the page marker constantly.
459
+ if (action && ['click', 'select', 'fill', 'upload'].includes(action.kind)) {
460
+ if (action.node !== undefined && idBaseOf(action.node))
461
+ return this.#freshInFrame(page, action);
462
+ const current = await this.#evaluate(`(() => { const c=window.__jevFast; return c ? [c.pageKey(), c.guard(c.nodes.get(${Number(action.node)}))] : null; })()`).catch(() => null);
463
+ return isDeepStrictEqual(current, [page.page_key, page.guards[String(action.node)]]);
464
+ }
465
+ if (!isDeepStrictEqual(await this.#evaluate(MARKER).catch(() => null), page.marker))
466
+ return false;
467
+ // A frame's text may tick (ads, clocks) without making a wait or scroll wrong; only a frame that navigated does.
468
+ return !page.frames?.length || this.#frames.sameDocuments(page.frames);
469
+ }
470
+ // A target in a frame: the main page's key, the frame's own key and the target's guard must all be as observed.
471
+ async #freshInFrame(page, action) {
472
+ const frame = this.#frames.ofNode(action.node);
473
+ const observed = frame && page.frames?.find((read) => read.id === frame.id);
474
+ if (!frame || !observed)
475
+ return false;
476
+ const [main, current] = await Promise.all([
477
+ this.#evaluate('window.__jevFast?.pageKey() ?? null').catch(() => null),
478
+ this.#evaluateIn(frame, `(() => { const c=window.__jevFast; return c ? [c.pageKey(), c.guard(c.nodes.get(${Number(action.node)}))] : null; })()`).catch(() => null),
479
+ ]);
480
+ return isDeepStrictEqual([main, current], [page.page_key, [observed.page_key, page.guards[String(action.node)]]]);
481
+ }
482
+ /** Runs one observed action. A scroll resolves to how far it moved the page (see `#scroll`). */
483
+ async act(action, page, text, { evenIfChanged = false } = {}) {
484
+ // Only a page-level action may skip the check, and only when the loop asks: Enter still needs its field focused (#pressEnter).
485
+ const skipCheck = evenIfChanged && PAGE_LEVEL_KINDS.includes(action.kind);
486
+ if (!skipCheck && !(await this.fresh(page, action)))
487
+ throw new StalePage('Page changed since this decision');
488
+ if (action.kind === 'wait')
489
+ return sleep(100);
490
+ if (action.kind === 'enter')
491
+ return this.#pressEnter(action);
492
+ if (action.kind === 'escape')
493
+ return this.dismissPopup();
494
+ let outcome;
495
+ if (action.kind === 'scroll')
496
+ outcome = await this.#scroll(action, page);
497
+ else
498
+ await this.#input(action, text);
499
+ this.#afterInput = action;
500
+ return outcome;
501
+ }
502
+ // jevway: the wheel goes where the reader found the pane that scrolls (or the fixed point on a page whose document
503
+ // scrolls), and the offsets before and after say whether it moved anything. The wheel lands asynchronously, so the
504
+ // offsets are polled until they stop moving: 500 ms with no movement means nothing moved, 1.5 s at most.
505
+ async #scroll(action, page) {
506
+ const offsets = async () => Number(await this.#evaluate(SCROLL_OFFSETS).catch(() => NaN));
507
+ const start = await offsets();
508
+ await this.#call('Input.dispatchMouseEvent', {
509
+ type: 'mouseWheel',
510
+ // Inside the viewport on any device: centre column, lower half.
511
+ x: action.at?.x ?? Math.round(page.w / 2),
512
+ y: action.at?.y ?? Math.round(page.h * 0.6),
513
+ deltaX: 0,
514
+ deltaY: action.delta,
515
+ });
516
+ if (Number.isNaN(start))
517
+ return {};
518
+ let last = start;
519
+ // The wheel has gone out: a stop ends only the poll, and the distance so far is reported.
520
+ const stop = whenAborted(this.#stop);
521
+ try {
522
+ for (const begun = Date.now(); Date.now() - begun < 1_500;) {
523
+ await Promise.race([sleep(50), stop.promise]);
524
+ if (this.#stop?.aborted)
525
+ break;
526
+ const now = await offsets();
527
+ if (Number.isNaN(now))
528
+ return {};
529
+ if (now === last && (now !== start || Date.now() - begun >= 500))
530
+ break;
531
+ last = now;
532
+ }
533
+ }
534
+ finally {
535
+ stop.release();
536
+ }
537
+ return { scrolled: Math.round(Math.abs(last - start)) };
538
+ }
539
+ /**
540
+ * Opt-in click at a viewport point, for controls the DOM reader can't report (canvas, iframes). The page
541
+ * must be unchanged since observation and the point must land on a visible, enabled element in the viewport.
542
+ * Returns what was hit, for the step record.
543
+ *
544
+ * @example
545
+ * const hit = await tab.clickAt({ x: 320, y: 410 }, page); // 'canvas'
546
+ */
547
+ async clickAt(point, page) {
548
+ if (!(await this.fresh(page)))
549
+ throw new StalePage('Page changed since this decision');
550
+ const hit = await this.#evaluate(`(${HIT_TEST})(${Number(point.x)}, ${Number(point.y)})`);
551
+ if (!hit)
552
+ throw new TargetUnreachable('Nothing visible and enabled at that point');
553
+ const { x, y } = point;
554
+ if (this.#touch) {
555
+ await this.#call('Input.dispatchTouchEvent', {
556
+ type: 'touchStart',
557
+ touchPoints: [{ x, y, radiusX: 6, radiusY: 6, force: 1 }],
558
+ });
559
+ await sleep(TAP_MS);
560
+ await this.#call('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
561
+ }
562
+ else {
563
+ for (const type of ['mousePressed', 'mouseReleased']) {
564
+ await this.#call('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
565
+ }
566
+ }
567
+ this.#afterInput = undefined;
568
+ return String(hit);
569
+ }
570
+ async close() {
571
+ for (const unsubscribe of this.#unsubscribe.splice(0))
572
+ unsubscribe();
573
+ this.#closed = true;
574
+ this.#frames.stop();
575
+ await this.cdp.send('Target.closeTarget', { targetId: this.targetId }).catch(() => undefined);
576
+ }
577
+ // A control that appears only on hover: move the pointer onto where it sits, then wait for it to show. Pointer move
578
+ // only (no click, no focus), so a touch screen takes it too: touch emulation applies :hover, and a tap could activate the
579
+ // container.
580
+ async #revealByHover(action) {
581
+ const node = `window.__jevFast?.nodes.get(${Number(action.node)})`;
582
+ const frame = this.#frameOf(action.node);
583
+ const centre = await this.#evaluateFor(frame, `(() => {
584
+ const e = ${node};
585
+ if (!e?.isConnected) return null;
586
+ const r = e.getBoundingClientRect();
587
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
588
+ })()`);
589
+ const offset = frame ? await this.#frames.offsetOf(frame) : { x: 0, y: 0 };
590
+ if (!centre || !offset)
591
+ throw new TargetUnreachable('Target changed or is covered');
592
+ await this.#call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: centre.x + offset.x, y: centre.y + offset.y });
593
+ for (let waited = 0; waited <= 500; waited += 50) {
594
+ const shown = await this.#evaluateFor(frame, `${node}?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) === true`);
595
+ if (shown)
596
+ return;
597
+ await sleep(50);
598
+ }
599
+ throw new TargetUnreachable('Hovering did not reveal the control');
600
+ }
601
+ async #input(action, text) {
602
+ if (action.hover)
603
+ await this.#revealByHover(action);
604
+ const frame = this.#frameOf(action.node);
605
+ const resolved = await this.#evaluateFor(frame, `(${RESOLVE_TARGET})(${JSON.stringify(action)})`).catch((error) => {
606
+ if (action.kind === 'select')
607
+ throw new Error('Dropdown execution was interrupted; inspect before retrying.');
608
+ throw error;
609
+ });
610
+ if (!resolved || resolved.covered) {
611
+ if (action.kind === 'select')
612
+ throw new Error('Dropdown execution was not confirmed; inspect before retrying.');
613
+ throw new TargetUnreachable('Target changed or is covered', Boolean(resolved?.byPopup), resolved?.coverNodes ?? []);
614
+ }
615
+ if (action.kind === 'select')
616
+ return;
617
+ // The point inside a frame, moved into the viewport and checked against whatever covers the frame in the page around it.
618
+ const target = frame ? await this.#frames.toViewport(frame, resolved) : resolved;
619
+ if ('covered' in target)
620
+ throw new TargetUnreachable('Target changed or is covered', false, target.coverNodes);
621
+ target.focused = resolved.focused;
622
+ // Focused in the page already: a press on the field's plain decoration would only take the focus away again.
623
+ if (!target.focused) {
624
+ if (frame)
625
+ await this.#frames.painted(frame);
626
+ const before = await this.#pressesReceived(frame);
627
+ if (this.#touch) {
628
+ await this.#call('Input.dispatchTouchEvent', {
629
+ type: 'touchStart',
630
+ touchPoints: [{ x: target.x, y: target.y, radiusX: 6, radiusY: 6, force: 1 }],
631
+ });
632
+ await sleep(TAP_MS);
633
+ await this.#call('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
634
+ }
635
+ else {
636
+ for (const type of ['mousePressed', 'mouseReleased']) {
637
+ await this.#call('Input.dispatchMouseEvent', { type, x: target.x, y: target.y, button: 'left', clickCount: 1 });
638
+ }
639
+ }
640
+ // The counter was registered before the page's scripts, so only a browser that never delivered the press leaves it
641
+ // unchanged. The press is never repeated here: a delivered click we failed to see must not run twice.
642
+ if (!(await this.#pageReceivedPress(before, frame))) {
643
+ throw new InputNotDelivered(`The page received no input from the click on "${action.label.slice(0, 60)}": the browser dropped it, so nothing was clicked. Retry the call or check the browser.`);
644
+ }
645
+ }
646
+ if (action.kind !== 'fill')
647
+ return;
648
+ const modifiers = process.platform === 'darwin' ? 4 : 2;
649
+ await this.#call('Input.dispatchKeyEvent', {
650
+ type: 'keyDown',
651
+ key: 'a',
652
+ code: 'KeyA',
653
+ modifiers,
654
+ commands: ['selectAll'],
655
+ });
656
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers });
657
+ // Real key events per character: autocompletes such as Booking's ignore a one-shot insertText. A line break
658
+ // as a key event would be Enter (a form submit), so text with line breaks is inserted in one go instead.
659
+ if (!text || /[\r\n]/.test(text))
660
+ return void (await this.#call('Input.insertText', { text: text ?? '' }));
661
+ for (const char of text) {
662
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyDown', key: char, text: char });
663
+ await this.#call('Input.dispatchKeyEvent', { type: 'keyUp', key: char });
664
+ }
665
+ }
666
+ // The trusted presses this document (the frame's, for a target in a frame) has received so far; undefined when it can't be
667
+ // read (no counter, navigated away). A press never crosses a frame boundary, so it is counted where it landed.
668
+ async #pressesReceived(frame) {
669
+ return this.#evaluateFor(frame, READ_INPUT).catch(() => undefined);
670
+ }
671
+ // False only while the count stays what it was before the press, after a short grace for a busy page. An unreadable
672
+ // count or a new document means the press was delivered or can't be judged, so it never blocks.
673
+ async #pageReceivedPress(before, frame) {
674
+ if (before === undefined)
675
+ return true;
676
+ for (let attempt = 0; attempt < INPUT_READS; attempt++) {
677
+ if (attempt)
678
+ await sleep(INPUT_POLL_MS);
679
+ if ((await this.#pressesReceived(frame)) !== before)
680
+ return true;
681
+ }
682
+ return false;
683
+ }
684
+ // Read-only wait: two animation frames, or visible autocomplete options (≤200ms) after typing into a combobox.
685
+ async #settleAfterInput() {
686
+ const action = this.#afterInput;
687
+ this.#afterInput = undefined;
688
+ if (!action || action.kind === 'wait')
689
+ return;
690
+ const frame = action.node !== undefined && idBaseOf(action.node) ? this.#frames.ofNode(action.node) : undefined;
691
+ const settle = { expression: `(${SETTLE})(${JSON.stringify(action)})`, awaitPromise: true, returnByValue: true };
692
+ await (frame ? this.#frames.evaluate(frame, settle.expression, settle) : this.#call('Runtime.evaluate', settle)).catch((error) => {
693
+ // Settling is best effort, but a page that does not answer at all is a browser error, not a reason to wait again.
694
+ if (error instanceof CdpError && TIMED_OUT.test(error.message))
695
+ throw error;
696
+ });
697
+ }
698
+ async #evaluate(expression) {
699
+ const response = await this.#call('Runtime.evaluate', { expression, returnByValue: true }).catch((error) => {
700
+ // A protocol error mid-navigation means the page moved on. A call that timed out means it stopped answering (a
701
+ // frozen renderer): that must end the call as a browser error, not be retried as "changed" 25 times at 30 s each.
702
+ throw error instanceof CdpError && !TIMED_OUT.test(error.message) ? new StalePage(error.message) : error;
703
+ });
704
+ if (response.exceptionDetails)
705
+ throw new StalePage('Document changed during evaluation');
706
+ return response.result?.value;
707
+ }
708
+ // The frame holding an observed node: undefined for the main document. A node whose frame has gone is stale.
709
+ #frameOf(node) {
710
+ if (node === undefined || !idBaseOf(node))
711
+ return undefined;
712
+ const frame = this.#frames.ofNode(node);
713
+ if (!frame)
714
+ throw new StalePage('The frame holding this target is gone');
715
+ return frame;
716
+ }
717
+ // In the main document, or in the frame's isolated world with the same errors as #evaluate.
718
+ #evaluateFor(frame, expression) {
719
+ return frame ? this.#evaluateIn(frame, expression) : this.#evaluate(expression);
720
+ }
721
+ async #evaluateIn(frame, expression) {
722
+ const response = await this.#frames.evaluate(frame, expression).catch((error) => {
723
+ throw error instanceof CdpError && !TIMED_OUT.test(error.message) ? new StalePage(error.message) : error;
724
+ });
725
+ if (response.exceptionDetails)
726
+ throw new StalePage('Document changed during evaluation');
727
+ return response.result?.value;
728
+ }
729
+ #call(method, params = {}) {
730
+ return this.cdp.send(method, params, this.sessionId);
731
+ }
732
+ }
733
+ _a = Tab;
734
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
735
+ // What a model image may measure in output pixels.
736
+ const MODEL_IMAGE = { width: 1024, height: 1536 };
737
+ /**
738
+ * Width and height of a JPEG, read from its start-of-frame marker. Null when the bytes are not a JPEG.
739
+ *
740
+ * @example
741
+ * jpegSize(await tab.screenshot({ format: 'jpeg' })); // { width: 393, height: 852 }
742
+ */
743
+ export const jpegSize = (base64) => {
744
+ const bytes = Buffer.from(base64, 'base64');
745
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8)
746
+ return null;
747
+ for (let at = 2; at + 9 <= bytes.length;) {
748
+ if (bytes[at] !== 0xff)
749
+ return null;
750
+ const marker = bytes[at + 1];
751
+ // Start of frame 0-15 except the table markers DHT (c4), JPG (c8) and DAC (cc).
752
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
753
+ return { width: bytes.readUInt16BE(at + 7), height: bytes.readUInt16BE(at + 5) };
754
+ }
755
+ // Fill bytes between segments, then a segment whose length counts its own two bytes.
756
+ at += marker === 0xff ? 1 : 2 + bytes.readUInt16BE(at + 2);
757
+ }
758
+ return null;
759
+ };
760
+ // How long setting up a followed tab may take before it counts as frozen; its load wait is capped apart, below.
761
+ const FOLLOW_MS = 5_000;
762
+ // How long a followed tab may stay on about:blank before it is read as it is (a popup a script writes into).
763
+ const POPUP_URL_MS = 3_000;
764
+ // A finger's tap lasts tens of milliseconds; a zero-length tap made some widgets open and close at once.
765
+ const TAP_MS = 60;
766
+ // Counts the pointer presses the page really received (trusted events only, so our synthetic outside press never counts).
767
+ const INPUT_COUNTER = `(() => {
768
+ if (window.__jevInput !== undefined) return;
769
+ window.__jevInput = 0;
770
+ for (const type of ['pointerdown', 'mousedown', 'touchstart']) {
771
+ window.addEventListener(type, (event) => { if (event.isTrusted) window.__jevInput++; }, true);
772
+ }
773
+ })()`;
774
+ // The count with its document's start time: a page that navigated restarts at 0, which must not read as "no input".
775
+ const READ_INPUT = `window.__jevInput === undefined ? undefined : performance.timeOrigin + ':' + window.__jevInput`;
776
+ // A dropped press is judged only after this many reads, INPUT_POLL_MS apart, so a slow main thread isn't mistaken for it.
777
+ const INPUT_READS = 4;
778
+ const INPUT_POLL_MS = 40;
779
+ const fingerprint = (state) => createHash('sha256')
780
+ .update(JSON.stringify([state.url, state.text, state.actions, state.scroll]))
781
+ .digest('hex');
782
+ // How far everything on the page is scrolled, summed: it changes whichever pane the wheel moved.
783
+ const SCROLL_OFFSETS = `(() => {
784
+ let sum = scrollY;
785
+ for (const e of document.querySelectorAll('*')) if (e.scrollTop) sum += e.scrollTop;
786
+ return sum;
787
+ })()`;
788
+ // The element at a point, looking through open shadow roots (a plain elementFromPoint stops at the host).
789
+ const DEEP_HIT = `(x, y) => {
790
+ let hit = document.elementFromPoint(x, y);
791
+ while (hit?.shadowRoot) {
792
+ const inner = hit.shadowRoot.elementFromPoint(x, y);
793
+ if (!inner || inner === hit) break;
794
+ hit = inner;
795
+ }
796
+ return hit;
797
+ }`;
798
+ // Numbered boxes in a closed shadow root: invisible to the snapshot's text walk and to page scripts.
799
+ // Covered elements (under a popover or sheet) get no box, so a number never sits on something else.
800
+ const MARKS_ID = '__jev_marks';
801
+ const DRAW_MARKS = `marks => {
802
+ document.getElementById('${MARKS_ID}')?.remove();
803
+ const onTop = ({ node, rect: { x, y, w, h } }) => {
804
+ const e = window.__jevFast?.nodes.get(node);
805
+ const hit = (${DEEP_HIT})(x + w / 2, y + h / 2);
806
+ return !e || !hit || e === hit || e.contains(hit) || hit.contains(e) || (e.labels && [...e.labels].some((l) => l.contains(hit)));
807
+ };
808
+ marks = marks.filter(onTop);
809
+ const host = document.createElement('div');
810
+ host.id = '${MARKS_ID}';
811
+ host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
812
+ const root = host.attachShadow({ mode: 'closed' });
813
+ for (const { label, rect: { x, y, w, h } } of marks) {
814
+ const box = document.createElement('div');
815
+ box.style.cssText = 'position:absolute;left:' + x + 'px;top:' + y + 'px;width:' + w + 'px;height:' + h + 'px;outline:2px solid #e11d48;outline-offset:-1px';
816
+ const tag = document.createElement('span');
817
+ tag.textContent = label;
818
+ tag.style.cssText = 'position:absolute;left:-1px;top:' + (y < 18 ? 0 : -18) + 'px;background:#e11d48;color:#fff;font:bold 14px/18px system-ui,sans-serif;padding:0 4px';
819
+ box.append(tag);
820
+ root.append(box);
821
+ }
822
+ document.documentElement.append(host);
823
+ return true;
824
+ }`;
825
+ // What a coordinate click would land on; null outside the viewport or on hidden/disabled content.
826
+ const HIT_TEST = `(x, y) => {
827
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return null;
828
+ const e = (${DEEP_HIT})(x, y);
829
+ if (!e || e.closest(':disabled,[aria-disabled="true"],[inert]')) return null;
830
+ if (!e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return null;
831
+ const name = (e.getAttribute('aria-label') || e.getAttribute('title') || e.innerText || '').trim().replace(/\\s+/g, ' ');
832
+ return (name || e.tagName.toLowerCase()).slice(0, 80);
833
+ }`;
834
+ const PRESS_OUTSIDE = `(() => {
835
+ document.activeElement?.blur();
836
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
837
+ const Event = type.startsWith('pointer') ? PointerEvent : MouseEvent;
838
+ document.body.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, composed: true }));
839
+ }
840
+ })()`;
841
+ // Same list as the snapshot's `interactive`: a field box holds no control but the one it frames.
842
+ const INTERACTIVE = 'a,button,input,select,textarea,[contenteditable="true"],' +
843
+ ['button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'menuitem', 'option', 'combobox'].map((role) => `[role="${role}"]`).join(',');
844
+ // Controls whose visible field is the box a person taps; toggles and buttons keep their own box however small.
845
+ const FIELD_LIKE = 'input:not([type="checkbox"],[type="radio"],[type="button"],[type="submit"],[type="reset"],[type="image"]),textarea,' +
846
+ '[contenteditable="true"],[role="combobox"],[role="textbox"],[role="searchbox"],[role="spinbutton"]';
847
+ // Resolves the observed node, rejects hidden/disabled/covered targets, and applies native selects in-page.
848
+ const RESOLVE_TARGET = `action => {
849
+ const e=window.__jevFast?.nodes.get(action.node);
850
+ if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
851
+ !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
852
+ if (action.kind==='fill' && (e.readOnly || e.getAttribute('aria-readonly')==='true')) return null;
853
+ // The centre of the visible box first, then four more points inside it; a hit must still land inside the target.
854
+ const point=()=>{ const r=window.__jevFast.clickRect(e);
855
+ if (!r.width || !r.height) return null;
856
+ for (const [px,py] of [[.5,.5],[.25,.5],[.75,.5],[.5,.25],[.5,.75]]) {
857
+ const x=r.x+r.width*px, y=r.y+r.height*py;
858
+ if (x>=0 && y>=0 && x<innerWidth && y<innerHeight && e.contains((${DEEP_HIT})(x,y))) return {x,y};
859
+ }
860
+ return null; };
861
+ // A text field or combobox: what a person sees and taps is the field around it, not the input inside.
862
+ const own=e.getBoundingClientRect();
863
+ const tiny=(own.width<12 || own.height<12) && e.matches(${JSON.stringify(FIELD_LIKE)});
864
+ // The field box: the nearest ancestor (3 levels at most) that holds no other control and is at least as big as the input.
865
+ const fieldBox=()=>{
866
+ for (let p=e.parentElement, up=0; p && up<3 && p!==document.body && p!==document.documentElement; p=p.parentElement, up++) {
867
+ // Another control in here means this is a toolbar or a row, not one field: give up rather than climb.
868
+ if (p.matches(${JSON.stringify(INTERACTIVE)}) ||
869
+ [...p.querySelectorAll(${JSON.stringify(INTERACTIVE)})].some(o=>o!==e && !e.contains(o) && o.type!=='hidden')) return null;
870
+ const r=p.getBoundingClientRect();
871
+ if (r.width<Math.max(own.width,12) || r.height<Math.max(own.height,12)) continue;
872
+ // Only this nearest box is tried: its centre must hit the box itself, never a banner or sheet over it.
873
+ const x=r.x+r.width/2, y=r.y+r.height/2;
874
+ return x>=0 && y>=0 && x<innerWidth && y<innerHeight && p.contains((${DEEP_HIT})(x,y)) ? {x,y} : null;
875
+ }
876
+ return null; };
877
+ // A fill gets focus on the input itself (a fixed operation, like a select's value); a click taps the field box.
878
+ const throughField=()=>{ const at=fieldBox();
879
+ if (!at || action.kind!=='fill') return at;
880
+ e.focus();
881
+ return e.matches(':focus') ? {...at, focused:true} : null; };
882
+ // A tiny input goes through its field box first; a fill the field's own decoration covers (magnifier, clear) goes there last.
883
+ const reach=()=>(tiny ? throughField() : null) || point() || (action.kind==='fill' ? throughField() : null);
884
+ // A target clipped by a scrolling sidebar or list is scrolled just into view once, as a person would.
885
+ let at=reach();
886
+ if (!at) { e.scrollIntoView({block:'nearest',inline:'nearest',behavior:'instant'}); at=reach(); }
887
+ if (!at) {
888
+ // Say what covers it: a popup layer can be closed with Escape, anything else can't be helped from here.
889
+ const r=window.__jevFast.clickRect(e), hit=(${DEEP_HIT})(r.x+r.width/2, r.y+r.height/2);
890
+ // The layer on top (fixed, sticky or modal, like a cookie banner): the ids of the observed nodes inside it, the target's own excluded.
891
+ const isLayer=n=>['fixed','sticky'].includes(getComputedStyle(n).position) ||
892
+ ['dialog','alertdialog'].includes(n.getAttribute('role')) || n.getAttribute('aria-modal')==='true';
893
+ let layer=hit;
894
+ while (layer && layer!==document.body && !isLayer(layer)) layer=layer.parentElement;
895
+ const coverNodes=layer && layer!==document.body
896
+ ? [...window.__jevFast.nodes].filter(([,n])=>n!==e && layer.contains(n)).map(([id])=>id) : [];
897
+ return {covered:true, coverNodes, byPopup:Boolean(hit?.closest('[role="listbox"],[role="menu"],[role="dialog"]:not([aria-modal="true"]),[role="tooltip"],[data-radix-popper-content-wrapper],[cmdk-list]'))};
898
+ }
899
+ const {x,y,focused}=at;
900
+ if (action.kind==='select') {
901
+ if (e.tagName!=='SELECT' || ![...e.options].some(o=>o.value===action.value &&
902
+ !o.disabled && !o.closest('optgroup[disabled]'))) return null;
903
+ e.value=action.value;
904
+ e.dispatchEvent(new Event('input',{bubbles:true}));
905
+ e.dispatchEvent(new Event('change',{bubbles:true}));
906
+ }
907
+ return {x,y,focused};
908
+ }`;
909
+ const SETTLE = `action => new Promise(resolve => {
910
+ const field=window.__jevFast?.nodes.get(action.node);
911
+ const autocomplete=action.kind==='fill' && field?.getAttribute('role')==='combobox';
912
+ let frames=0, stopped=false, extended=false;
913
+ const started=performance.now();
914
+ const finish=()=>{stopped=true;resolve()};
915
+ let deadline=setTimeout(finish,autocomplete ? 200 : 50);
916
+ // jevway: a menu or popover fading in is read as hover-only until it has opened, and a person waits for it too. The wait
917
+ // is capped at 400 ms, and endless animations (spinners, pulsing dots) don't count.
918
+ const animating=()=>document.getAnimations().some(a=>a.playState==='running' && a.effect?.getComputedTiming().endTime!==Infinity);
919
+ const ready=()=>{
920
+ if (stopped) return;
921
+ const moving=animating();
922
+ if (moving && !extended) {
923
+ extended=true;
924
+ clearTimeout(deadline);
925
+ deadline=setTimeout(finish,Math.max(0,400-(performance.now()-started)));
926
+ }
927
+ const ids=(field?.getAttribute('aria-controls')||field?.getAttribute('aria-owns')||'').split(/\\s+/).filter(Boolean);
928
+ const roots=ids.length ? ids.map(id=>document.getElementById(id)).filter(Boolean) : [document];
929
+ const options=roots.flatMap(root=>[...root.querySelectorAll('[role="option"]')]);
930
+ if (++frames>=2 && !moving && (!autocomplete || options.some(e=>{
931
+ const r=e.getBoundingClientRect();
932
+ return r.width && r.height && r.bottom>0 && r.top<innerHeight &&
933
+ e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});
934
+ }))) finish();
935
+ else requestAnimationFrame(ready);
936
+ };
937
+ requestAnimationFrame(ready);
938
+ })`;