@evan_zhong/browser-attach 0.21.0 → 0.22.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.
@@ -1,339 +1,342 @@
1
- import { deadline, waitForTarget, delivery } from './actionability.js';
2
- import { clickOptions } from './click.js';
3
- import { timingOption, pause } from './motion.js';
4
-
5
- const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
6
-
7
- export function inputMode(args) {
8
- const mode = args.mode ?? 'native';
9
- if (!['native', 'dom'].includes(mode)) throw new Error('mode must be native or dom');
10
- return mode;
11
- }
12
-
13
- async function bounded(operation, message, ms = 5000) {
14
- let timer;
15
- try { return await Promise.race([operation, new Promise((resolve, reject) => {
16
- timer = setTimeout(() => reject(new Error(`${message} Outcome is unknown. ${BUSY}`)), ms);
17
- })]); }
18
- finally { clearTimeout(timer); }
19
- }
20
-
21
- export function createInput(cdp, value, sessions) {
22
- const pending = new Map();
23
- function event(tabId, method, params, sessionId) {
24
- const operation = pending.get(tabId);
25
- if (!operation) return;
26
- if (method === 'Page.javascriptDialogOpening') {
27
- operation.reject(new Error('The key action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'));
28
- }
29
- if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
30
- try { if (JSON.parse(params.payload).delivered === true) operation.resolve(); }
31
- catch (error) { operation.reject(error); }
32
- }
33
- function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during keyboard input; outcome is unknown. Observe before retrying.')); }
34
-
35
- async function focusedNode(tabId, budget, worlds, frames) {
36
- frames ||= await budget.run(() => sessions.frames(tabId));
37
- let frameId = frames[0].id;
38
- const visited = new Set();
39
- while (true) {
40
- if (visited.has(frameId)) throw new Error('Focus frame cycle; no input was dispatched.');
41
- visited.add(frameId);
42
- let world = worlds?.get(frameId);
43
- if (!world) {
44
- if (worlds || !frames.some(frame => frame.id === frameId)) throw new Error('Focused frame is unavailable; no input was dispatched.');
45
- const target = sessions.target(tabId, frameId);
46
- const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' }));
47
- world = { target, contextId: executionContextId };
48
- }
49
- const active = await budget.run(() => cdp(world.target, 'Runtime.evaluate', { contextId: world.contextId,
50
- expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' }));
51
- value(active);
52
- if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
53
- const { node } = await budget.run(() => cdp(world.target, 'DOM.describeNode', { objectId: active.result.objectId }));
54
- if (node.frameId) frameId = node.frameId;
55
- else return { target: world.target, frameId, objectId: active.result.objectId, backendNodeId: node.backendNodeId };
56
- }
57
- }
58
-
59
- async function pressKey(tabId, chord, args = {}) {
60
- const budget = args.budget || deadline(args.timeout_ms);
61
- const send = (...params) => budget.run(() => cdp(...params));
62
- const { params, text } = args.character != null ? characterParameters(args.character) : keyParameters(chord);
63
- const name = `__attachKey_${crypto.randomUUID().replaceAll('-', '')}`;
64
- const contexts = new Set(), guards = [], omittedFrames = [];
65
- const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
66
- receipt.catch(() => {});
67
- try {
68
- const frames = await budget.run(() => sessions.frames(tabId, send));
69
- const worlds = new Map();
70
- for (const frame of frames) {
71
- const target = sessions.target(tabId, frame.id);
72
- let contextId;
73
- try {
74
- const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId: frame.id, worldName: 'browser-attach-input' });
75
- if (!executionContextId) throw new Error('Could not create keyboard verification context; no input was dispatched.');
76
- contextId = executionContextId;
77
- worlds.set(frame.id, { target, contextId, frameId: frame.id });
78
- contexts.add(JSON.stringify([target.sessionId, executionContextId]));
79
- await send(target, 'Runtime.addBinding', { name, executionContextId });
80
- const installed = await send(target, 'Runtime.evaluate', {
81
- contextId: executionContextId, expression: `(${installKeyReceipt.toString()})(${JSON.stringify(name)},${JSON.stringify(params)},${budget.remaining() + 500})`,
82
- objectGroup: 'browser-attach',
83
- });
84
- if (installed.exceptionDetails) value(installed);
85
- if (!installed.result?.objectId) throw new Error('Could not install keyboard verification; no input was dispatched.');
86
- guards.push({ target, objectId: installed.result.objectId });
87
- } catch (error) {
88
- if (frame.id === frames[0].id) throw error;
89
- contexts.delete(JSON.stringify([target.sessionId, contextId]));
90
- omittedFrames.push({ frame_id: frame.id, reason: error.message });
91
- }
92
- }
93
- const focused = await focusedNode(tabId, budget, worlds, frames);
94
- await waitForTarget(cdp, value, focused.target, focused, budget);
95
- if (args.expectedNode) {
96
- const expected = args.expectedNode;
97
- const matches = focused.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
98
- functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true }));
99
- if (!matches) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
100
- }
101
- budget.dispatch();
102
- // Queue the complete chord before waiting for acknowledgements. Always
103
- // enqueue keyUp, even if keyDown fails or opens a modal.
104
- const commands = [send(focused.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
105
- if (text != null && args.character == null) commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
106
- commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
107
- await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
108
- return { sent: chord, method: 'native', delivery_verified: true, application_result: 'not_checked',
109
- ...(omittedFrames.length ? { verification_omitted_frames: omittedFrames } : {}), observe_again: true };
110
- } finally {
111
- pending.delete(tabId);
112
- const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
113
- cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
114
- await bounded(Promise.allSettled(cleanup), 'Keyboard verification cleanup timed out.', 250).catch(() => {});
115
- }
116
- }
117
-
118
- async function fill(tabId, node, args) {
119
- if (!node || typeof args.text !== 'string') throw new Error('fill requires ref or selector, and text');
120
- const mode = inputMode(args);
121
- const budget = args.budget || deadline(args.timeout_ms), target = node.target;
122
- const send = (method, params) => budget.run(() => cdp(target, method, params));
123
- await waitForTarget(cdp, value, target, node, budget, { editable: true });
124
- budget.dispatch();
125
- value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
126
- functionDeclaration: prepareFill.toString(), arguments: [{ value: mode }, { value: args.text }] }));
127
- if (mode === 'native') await send('Input.insertText', { text: args.text });
128
- const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
129
- functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
130
- if (matches !== true) throw new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`);
131
- return { filled: true, method: mode, value_verified: true, ...(mode === 'dom' ? { events_trusted: false } : {}), application_result: 'not_checked', observe_again: true };
132
- }
133
-
134
- async function domClick(tabId, node, args) {
135
- if (!node || args.x != null || args.y != null) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
136
- clickOptions(args);
137
- const budget = args.budget || deadline(args.timeout_ms), target = node.target;
138
- const send = (method, params) => budget.run(() => cdp(target, method, params));
139
- await waitForTarget(cdp, value, target, node, budget);
140
- budget.dispatch();
141
- const result = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
142
- functionDeclaration: clickElement.toString() }));
143
- return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
144
- }
145
- async function selectOption(tabId, node, args) {
146
- selectOptions(args);
147
- const budget = args.budget || deadline(args.timeout_ms);
148
- const choice = { value: args.value, label: args.label, index: args.index };
149
- value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
150
- objectId: node.objectId, functionDeclaration: selectElement.toString(),
151
- arguments: [{ value: choice }, { value: false }], returnByValue: true,
152
- })));
153
- budget.dispatch();
154
- const result = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
155
- objectId: node.objectId, functionDeclaration: selectElement.toString(),
156
- arguments: [{ value: choice }], returnByValue: true,
157
- })));
158
- return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
159
- }
160
- async function typeText(tabId, node, args) {
161
- typeOptions(args);
162
- const budget = args.budget || deadline(args.timeout_ms);
163
- if (args.focused) node = await focusedNode(tabId, budget);
164
- await waitForTarget(cdp, value, node.target, node, budget, { editable: true });
165
- if (!args.focused) {
166
- budget.dispatch();
167
- value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
168
- functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
169
- }
170
- let typed = 0; const length = Array.from(args.text).length;
171
- for (const character of args.text) {
172
- const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
173
- functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true })));
174
- if (!focused) throw new Error(`Typing stopped after ${typed} characters: original field detached or lost focus. Outcome is unknown; do not replay.`);
175
- try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
176
- catch (error) { error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
177
- typed++;
178
- if (typed < length) await pause(budget, args.delay_ms);
179
- }
180
- return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
181
- }
182
- return { pressKey, fill, typeText, domClick, selectOption, event, cancel };
183
- }
184
-
185
- function installKeyReceipt(name, params, expiryMs) {
186
- const report = globalThis[name];
187
- function listener(event) {
188
- const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
189
- if (event.isTrusted && event.code === params.code && event.key === params.key && modifiers === params.modifiers) {
190
- report(JSON.stringify({ delivered: true })); stop();
191
- }
192
- }
193
- let expiry;
194
- function stop() { clearTimeout(expiry); globalThis.removeEventListener('keydown', listener, true); delete globalThis[name]; }
195
- globalThis.addEventListener('keydown', listener, true);
196
- expiry = setTimeout(stop, expiryMs);
197
- return { stop };
198
- }
199
-
200
- const NAMED_KEYS = {
201
- Enter: [13, 'Enter'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
202
- Insert: [45, 'Insert'], Delete: [46, 'Delete'], ArrowLeft: [37, 'ArrowLeft'], ArrowRight: [39, 'ArrowRight'],
203
- ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], Home: [36, 'Home'], End: [35, 'End'],
204
- PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
205
- };
206
- // Deterministic US keyboard mapping: [plain, shifted, virtual key, physical code].
207
- const PRINTABLE_KEYS = [
208
- [' ', ' ', 32, 'Space'], ['0', ')', 48, 'Digit0'], ['1', '!', 49, 'Digit1'], ['2', '@', 50, 'Digit2'],
209
- ['3', '#', 51, 'Digit3'], ['4', '$', 52, 'Digit4'], ['5', '%', 53, 'Digit5'], ['6', '^', 54, 'Digit6'],
210
- ['7', '&', 55, 'Digit7'], ['8', '*', 56, 'Digit8'], ['9', '(', 57, 'Digit9'],
211
- [';', ':', 186, 'Semicolon'], ['=', '+', 187, 'Equal'], [',', '<', 188, 'Comma'], ['-', '_', 189, 'Minus'],
212
- ['.', '>', 190, 'Period'], ['/', '?', 191, 'Slash'], ['`', '~', 192, 'Backquote'],
213
- ['[', '{', 219, 'BracketLeft'], ['\\', '|', 220, 'Backslash'], [']', '}', 221, 'BracketRight'], ["'", '"', 222, 'Quote'],
214
- ];
215
-
216
- export function keyParameters(chord) {
217
- if (typeof chord !== 'string') throw new Error('key must be a string');
218
- const modifiers = { Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 };
219
- const parts = chord.split('+');
220
- let key = parts.pop();
221
- // A literal plus may appear alone or at the end of a chord: +, Control++.
222
- if (key === '' && parts.at(-1) === '') { key = '+'; parts.pop(); }
223
- let mask = 0;
224
- for (const part of parts) {
225
- if (!Object.hasOwn(modifiers, part)) throw new Error(`Unknown modifier: ${part}`);
226
- mask |= modifiers[part];
227
- }
228
- let entry = Object.hasOwn(NAMED_KEYS, key) ? NAMED_KEYS[key] : null, text;
229
- if (!entry && /^F([1-9]|1[0-2])$/.test(key)) entry = [111 + Number(key.slice(1)), key];
230
- if (!entry && /^[a-zA-Z]$/.test(key)) {
231
- entry = [key.toUpperCase().charCodeAt(0), `Key${key.toUpperCase()}`];
232
- key = mask & 8 ? key.toUpperCase() : key.toLowerCase();
233
- if ((mask & ~8) === 0) text = key;
234
- }
235
- if (!entry) {
236
- const row = PRINTABLE_KEYS.find(([plain, shifted, , code]) => key === plain || key === shifted || key === code || (key === 'Plus' && shifted === '+'));
237
- if (row) {
238
- const [plain, shifted, vk, code] = row;
239
- if (plain !== shifted && (key === shifted || key === 'Plus')) mask |= 8;
240
- key = mask & 8 ? shifted : plain;
241
- entry = [vk, code];
242
- if ((mask & ~8) === 0) text = key;
243
- }
244
- }
245
- if (!entry) throw new Error('Unsupported key. Use F1-F12, a named navigation/editing key, ASCII letter/digit or US punctuation.');
246
- if ((mask & ~8) === 0 && key === 'Enter') text = '\r';
247
- if (mask === 0 && key === 'Tab') text = '\t';
248
- return { params: { key, code: entry[1], windowsVirtualKeyCode: entry[0], nativeVirtualKeyCode: entry[0], modifiers: mask }, text };
249
- }
250
-
251
- function prepareFill(mode, text) {
252
- const tag = this.tagName?.toLowerCase();
253
- if (!this.isConnected) throw new Error('Field is detached');
254
- if (this.disabled || this.readOnly || this.closest('[inert]')) throw new Error('Field is disabled, read-only or inert');
255
- if (!['input', 'textarea'].includes(tag) && !this.isContentEditable) throw new Error('Ref is not an editable field');
256
- if (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(this.type)) throw new Error('Input type cannot be filled with text');
257
- if (mode === 'dom') {
258
- const win = this.ownerDocument.defaultView;
259
- if (this.isContentEditable) this.textContent = text;
260
- else {
261
- const prototype = tag === 'textarea' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype;
262
- Object.getOwnPropertyDescriptor(prototype, 'value').set.call(this, text);
263
- }
264
- this.dispatchEvent(new win.InputEvent('input', { bubbles: true, composed: true, inputType: 'insertReplacementText', data: text }));
265
- this.dispatchEvent(new win.Event('change', { bubbles: true }));
266
- } else {
267
- this.focus();
268
- const active = this.getRootNode().activeElement;
269
- if (active !== this && !this.contains(active)) throw new Error('Field did not receive focus');
270
- if (this.isContentEditable) {
271
- const range = this.ownerDocument.createRange(); range.selectNodeContents(this);
272
- const selection = this.ownerDocument.getSelection(); selection.removeAllRanges(); selection.addRange(range);
273
- } else this.select();
274
- }
275
- return true;
276
- }
277
-
278
- function verifyFill(text) {
279
- // Native editing may represent line breaks using nested blocks or <br>.
280
- return this.isConnected && (this.isContentEditable ? this.textContent === text || this.innerText === text : this.value === text);
281
- }
282
-
283
- function clickElement() {
284
- const element = this.nodeType === 3 ? this.parentElement : this;
285
- if (!element?.isConnected) throw new Error('Element is detached');
286
- if (element.matches(':disabled') || element.closest('[inert]')) throw new Error('Element is disabled or inert');
287
- const win = element.ownerDocument.defaultView, rect = element.getBoundingClientRect(), style = win.getComputedStyle(element);
288
- if (style.visibility !== 'visible' || !rect.width || !rect.height) throw new Error('Element is not visible');
289
- if (!(element instanceof win.HTMLElement)) throw new Error('DOM click supports HTML elements only; use native input for this ref.');
290
- win.HTMLElement.prototype.click.call(element);
291
- return { clicked: true };
292
- }
293
-
294
- function activeElement() {
295
- let element = document.activeElement;
296
- while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
297
- return element;
298
- }
299
-
300
- export function selectOptions(args) {
301
- if (['value', 'label', 'index'].filter(key => args[key] != null).length !== 1) throw new Error('Provide exactly one of value, label or index');
302
- if (args.index != null && (!Number.isSafeInteger(args.index) || args.index < 0)) throw new Error('index must be a nonnegative safe integer');
303
- for (const key of ['value', 'label']) if (args[key] != null && typeof args[key] !== 'string') throw new Error(`${key} must be a string`);
304
- }
305
-
306
- export function selectElement(choice, apply = true) {
307
- if (this.tagName !== 'SELECT' || !this.isConnected) throw new Error('Target must be a connected HTML select');
308
- for (let parent = this; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
309
- if (parent.matches(':disabled') || parent.hasAttribute('inert')) throw new Error('Select is disabled or inert');
310
- }
311
- const options = Array.from(this.options);
312
- const matches = options.filter((option, index) => choice.index != null ? index === choice.index : choice.value != null ? option.value === choice.value : option.label === choice.label);
313
- if (matches.length !== 1) throw new Error(`Option must match exactly once; matched ${matches.length}. Use an explicit index for duplicate labels/values. No value was set.`);
314
- const option = matches[0];
315
- if (option.disabled || option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.disabled) throw new Error('Option is disabled; no value was set');
316
- if (!apply) return true;
317
- const win = this.ownerDocument.defaultView;
318
- const index = options.indexOf(option);
319
- Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'selectedIndex').set.call(this, index);
320
- this.dispatchEvent(new win.Event('input', { bubbles: true, composed: true }));
321
- this.dispatchEvent(new win.Event('change', { bubbles: true }));
322
- if (!this.isConnected || this.selectedIndex !== index || this.options[index] !== option || !option.selected) throw new Error('Select did not retain the requested option. Outcome is unknown; observe before continuing.');
323
- return { selected: true, index, value: option.value, label: option.label, value_verified: true };
324
- }
325
-
326
- export function typeOptions(args) {
327
- if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
328
- timingOption(args.delay_ms ?? 0, 'delay_ms');
329
- }
330
-
331
- export function characterParameters(character) {
332
- if (typeof character !== 'string' || Array.from(character).length !== 1 || /[\r\n\t\x00-\x1f]/.test(character)) throw new Error('type accepts printable characters; use press_key for control keys');
333
- try {
334
- const result = keyParameters(/^[A-Z]$/.test(character) ? `Shift+${character}` : character);
335
- return { params: { ...result.params, key: character }, text: character };
336
- } catch {
337
- return { params: { key: character, code: '', windowsVirtualKeyCode: 0, nativeVirtualKeyCode: 0, modifiers: 0 }, text: character };
338
- }
339
- }
1
+ import { deadline, waitForTarget, delivery } from './actionability.js';
2
+ import { clickOptions } from './click.js';
3
+ import { timingOption, pause } from './motion.js';
4
+
5
+ const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
6
+
7
+ export function inputMode(args) {
8
+ const mode = args.mode ?? 'native';
9
+ if (!['native', 'dom'].includes(mode)) throw new Error('mode must be native or dom');
10
+ return mode;
11
+ }
12
+
13
+ async function bounded(operation, message, ms = 5000) {
14
+ let timer;
15
+ try { return await Promise.race([operation, new Promise((resolve, reject) => {
16
+ timer = setTimeout(() => reject(Object.assign(new Error(`${message} Outcome is unknown. ${BUSY}`), { outcome_unknown: true })), ms);
17
+ })]); }
18
+ finally { clearTimeout(timer); }
19
+ }
20
+
21
+ export function createInput(cdp, value, sessions) {
22
+ const pending = new Map();
23
+ function event(tabId, method, params, sessionId) {
24
+ const operation = pending.get(tabId);
25
+ if (!operation) return;
26
+ if (method === 'Page.javascriptDialogOpening') {
27
+ operation.reject(Object.assign(new Error('The key action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'), { outcome_unknown: true }));
28
+ }
29
+ if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
30
+ try { if (JSON.parse(params.payload).delivered === true) operation.resolve(); }
31
+ catch (error) { operation.reject(error); }
32
+ }
33
+ function cancel(tabId) { pending.get(tabId)?.reject(Object.assign(new Error('Tab detached during keyboard input; outcome is unknown. Observe before retrying.'), { outcome_unknown: true })); }
34
+
35
+ async function focusedNode(tabId, budget, worlds, frames) {
36
+ frames ||= await budget.run(() => sessions.frames(tabId));
37
+ let frameId = frames[0].id;
38
+ const visited = new Set();
39
+ while (true) {
40
+ if (visited.has(frameId)) throw new Error('Focus frame cycle; no input was dispatched.');
41
+ visited.add(frameId);
42
+ let world = worlds?.get(frameId);
43
+ if (!world) {
44
+ if (worlds || !frames.some(frame => frame.id === frameId)) throw new Error('Focused frame is unavailable; no input was dispatched.');
45
+ const target = sessions.target(tabId, frameId);
46
+ const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' }));
47
+ world = { target, contextId: executionContextId };
48
+ }
49
+ const active = await budget.run(() => cdp(world.target, 'Runtime.evaluate', { contextId: world.contextId,
50
+ expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' }));
51
+ value(active);
52
+ if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
53
+ const { node } = await budget.run(() => cdp(world.target, 'DOM.describeNode', { objectId: active.result.objectId }));
54
+ if (node.frameId) frameId = node.frameId;
55
+ else return { target: world.target, frameId, objectId: active.result.objectId, backendNodeId: node.backendNodeId };
56
+ }
57
+ }
58
+
59
+ async function pressKey(tabId, chord, args = {}) {
60
+ const budget = args.budget || deadline(args.timeout_ms);
61
+ const send = (...params) => budget.run(() => cdp(...params));
62
+ const { params, text } = args.character != null ? characterParameters(args.character) : keyParameters(chord);
63
+ const name = `__attachKey_${crypto.randomUUID().replaceAll('-', '')}`;
64
+ const contexts = new Set(), guards = [], omittedFrames = [];
65
+ const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
66
+ receipt.catch(() => {});
67
+ try {
68
+ const frames = await budget.run(() => sessions.frames(tabId, send));
69
+ const worlds = new Map();
70
+ for (const frame of frames) {
71
+ const target = sessions.target(tabId, frame.id);
72
+ let contextId;
73
+ try {
74
+ const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId: frame.id, worldName: 'browser-attach-input' });
75
+ if (!executionContextId) throw new Error('Could not create keyboard verification context; no input was dispatched.');
76
+ contextId = executionContextId;
77
+ worlds.set(frame.id, { target, contextId, frameId: frame.id });
78
+ contexts.add(JSON.stringify([target.sessionId, executionContextId]));
79
+ await send(target, 'Runtime.addBinding', { name, executionContextId });
80
+ const installed = await send(target, 'Runtime.evaluate', {
81
+ contextId: executionContextId, expression: `(${installKeyReceipt.toString()})(${JSON.stringify(name)},${JSON.stringify(params)},${budget.remaining() + 500})`,
82
+ objectGroup: 'browser-attach',
83
+ });
84
+ if (installed.exceptionDetails) value(installed);
85
+ if (!installed.result?.objectId) throw new Error('Could not install keyboard verification; no input was dispatched.');
86
+ guards.push({ target, objectId: installed.result.objectId });
87
+ } catch (error) {
88
+ if (frame.id === frames[0].id) throw error;
89
+ contexts.delete(JSON.stringify([target.sessionId, contextId]));
90
+ omittedFrames.push({ frame_id: frame.id, reason: error.message });
91
+ }
92
+ }
93
+ const focused = await focusedNode(tabId, budget, worlds, frames);
94
+ await waitForTarget(cdp, value, focused.target, focused, budget);
95
+ if (args.expectedNode) {
96
+ const expected = args.expectedNode;
97
+ const matches = focused.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
98
+ functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true }));
99
+ if (!matches) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
100
+ }
101
+ budget.dispatch();
102
+ // Queue the complete chord before waiting for acknowledgements. Always
103
+ // enqueue keyUp, even if keyDown fails or opens a modal.
104
+ const commands = [send(focused.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
105
+ if (text != null && args.character == null) commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
106
+ commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
107
+ await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
108
+ return { sent: chord, method: 'native', delivery_verified: true, application_result: 'not_checked',
109
+ ...(omittedFrames.length ? { verification_omitted_frames: omittedFrames } : {}), observe_again: true };
110
+ } finally {
111
+ pending.delete(tabId);
112
+ const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
113
+ cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
114
+ await bounded(Promise.allSettled(cleanup), 'Keyboard verification cleanup timed out.', 250).catch(() => {});
115
+ }
116
+ }
117
+
118
+ async function fill(tabId, node, args) {
119
+ if (!node || typeof args.text !== 'string') throw new Error('fill requires ref or selector, and text');
120
+ const mode = inputMode(args);
121
+ const budget = args.budget || deadline(args.timeout_ms), target = node.target;
122
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
123
+ await waitForTarget(cdp, value, target, node, budget, { editable: true });
124
+ budget.dispatch();
125
+ value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
126
+ functionDeclaration: prepareFill.toString(), arguments: [{ value: mode }, { value: args.text }] }));
127
+ if (mode === 'native') await send('Input.insertText', { text: args.text });
128
+ const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
129
+ functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
130
+ if (matches !== true) throw Object.assign(new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`), { outcome_unknown: true });
131
+ return { filled: true, method: mode, value_verified: true, ...(mode === 'dom' ? { events_trusted: false } : {}), application_result: 'not_checked', observe_again: true };
132
+ }
133
+
134
+ async function domClick(tabId, node, args) {
135
+ if (!node || args.x != null || args.y != null) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
136
+ clickOptions(args);
137
+ const budget = args.budget || deadline(args.timeout_ms), target = node.target;
138
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
139
+ await waitForTarget(cdp, value, target, node, budget);
140
+ budget.dispatch();
141
+ const result = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
142
+ functionDeclaration: clickElement.toString() }));
143
+ return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
144
+ }
145
+ async function selectOption(tabId, node, args) {
146
+ selectOptions(args);
147
+ const budget = args.budget || deadline(args.timeout_ms);
148
+ const choice = { value: args.value, label: args.label, index: args.index };
149
+ value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
150
+ objectId: node.objectId, functionDeclaration: selectElement.toString(),
151
+ arguments: [{ value: choice }, { value: false }], returnByValue: true,
152
+ })));
153
+ budget.dispatch();
154
+ let result;
155
+ try {
156
+ result = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
157
+ objectId: node.objectId, functionDeclaration: selectElement.toString(),
158
+ arguments: [{ value: choice }], returnByValue: true,
159
+ })));
160
+ } catch (error) { error.outcome_unknown = true; throw error; }
161
+ return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
162
+ }
163
+ async function typeText(tabId, node, args) {
164
+ typeOptions(args);
165
+ const budget = args.budget || deadline(args.timeout_ms);
166
+ if (args.focused) node = await focusedNode(tabId, budget);
167
+ await waitForTarget(cdp, value, node.target, node, budget, { editable: true });
168
+ if (!args.focused) {
169
+ budget.dispatch();
170
+ value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
171
+ functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
172
+ }
173
+ let typed = 0; const length = Array.from(args.text).length;
174
+ for (const character of args.text) {
175
+ const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
176
+ functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true })));
177
+ if (!focused) throw Object.assign(new Error(`Typing stopped after ${typed} characters: original field detached or lost focus. Outcome is unknown; do not replay.`), { outcome_unknown: true });
178
+ try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
179
+ catch (error) { error.outcome_unknown = true; error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
180
+ typed++;
181
+ if (typed < length) await pause(budget, args.delay_ms);
182
+ }
183
+ return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
184
+ }
185
+ return { pressKey, fill, typeText, domClick, selectOption, event, cancel };
186
+ }
187
+
188
+ function installKeyReceipt(name, params, expiryMs) {
189
+ const report = globalThis[name];
190
+ function listener(event) {
191
+ const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
192
+ if (event.isTrusted && event.code === params.code && event.key === params.key && modifiers === params.modifiers) {
193
+ report(JSON.stringify({ delivered: true })); stop();
194
+ }
195
+ }
196
+ let expiry;
197
+ function stop() { clearTimeout(expiry); globalThis.removeEventListener('keydown', listener, true); delete globalThis[name]; }
198
+ globalThis.addEventListener('keydown', listener, true);
199
+ expiry = setTimeout(stop, expiryMs);
200
+ return { stop };
201
+ }
202
+
203
+ const NAMED_KEYS = {
204
+ Enter: [13, 'Enter'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
205
+ Insert: [45, 'Insert'], Delete: [46, 'Delete'], ArrowLeft: [37, 'ArrowLeft'], ArrowRight: [39, 'ArrowRight'],
206
+ ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], Home: [36, 'Home'], End: [35, 'End'],
207
+ PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
208
+ };
209
+ // Deterministic US keyboard mapping: [plain, shifted, virtual key, physical code].
210
+ const PRINTABLE_KEYS = [
211
+ [' ', ' ', 32, 'Space'], ['0', ')', 48, 'Digit0'], ['1', '!', 49, 'Digit1'], ['2', '@', 50, 'Digit2'],
212
+ ['3', '#', 51, 'Digit3'], ['4', '$', 52, 'Digit4'], ['5', '%', 53, 'Digit5'], ['6', '^', 54, 'Digit6'],
213
+ ['7', '&', 55, 'Digit7'], ['8', '*', 56, 'Digit8'], ['9', '(', 57, 'Digit9'],
214
+ [';', ':', 186, 'Semicolon'], ['=', '+', 187, 'Equal'], [',', '<', 188, 'Comma'], ['-', '_', 189, 'Minus'],
215
+ ['.', '>', 190, 'Period'], ['/', '?', 191, 'Slash'], ['`', '~', 192, 'Backquote'],
216
+ ['[', '{', 219, 'BracketLeft'], ['\\', '|', 220, 'Backslash'], [']', '}', 221, 'BracketRight'], ["'", '"', 222, 'Quote'],
217
+ ];
218
+
219
+ export function keyParameters(chord) {
220
+ if (typeof chord !== 'string') throw new Error('key must be a string');
221
+ const modifiers = { Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 };
222
+ const parts = chord.split('+');
223
+ let key = parts.pop();
224
+ // A literal plus may appear alone or at the end of a chord: +, Control++.
225
+ if (key === '' && parts.at(-1) === '') { key = '+'; parts.pop(); }
226
+ let mask = 0;
227
+ for (const part of parts) {
228
+ if (!Object.hasOwn(modifiers, part)) throw new Error(`Unknown modifier: ${part}`);
229
+ mask |= modifiers[part];
230
+ }
231
+ let entry = Object.hasOwn(NAMED_KEYS, key) ? NAMED_KEYS[key] : null, text;
232
+ if (!entry && /^F([1-9]|1[0-2])$/.test(key)) entry = [111 + Number(key.slice(1)), key];
233
+ if (!entry && /^[a-zA-Z]$/.test(key)) {
234
+ entry = [key.toUpperCase().charCodeAt(0), `Key${key.toUpperCase()}`];
235
+ key = mask & 8 ? key.toUpperCase() : key.toLowerCase();
236
+ if ((mask & ~8) === 0) text = key;
237
+ }
238
+ if (!entry) {
239
+ const row = PRINTABLE_KEYS.find(([plain, shifted, , code]) => key === plain || key === shifted || key === code || (key === 'Plus' && shifted === '+'));
240
+ if (row) {
241
+ const [plain, shifted, vk, code] = row;
242
+ if (plain !== shifted && (key === shifted || key === 'Plus')) mask |= 8;
243
+ key = mask & 8 ? shifted : plain;
244
+ entry = [vk, code];
245
+ if ((mask & ~8) === 0) text = key;
246
+ }
247
+ }
248
+ if (!entry) throw new Error('Unsupported key. Use F1-F12, a named navigation/editing key, ASCII letter/digit or US punctuation.');
249
+ if ((mask & ~8) === 0 && key === 'Enter') text = '\r';
250
+ if (mask === 0 && key === 'Tab') text = '\t';
251
+ return { params: { key, code: entry[1], windowsVirtualKeyCode: entry[0], nativeVirtualKeyCode: entry[0], modifiers: mask }, text };
252
+ }
253
+
254
+ function prepareFill(mode, text) {
255
+ const tag = this.tagName?.toLowerCase();
256
+ if (!this.isConnected) throw new Error('Field is detached');
257
+ if (this.disabled || this.readOnly || this.closest('[inert]')) throw new Error('Field is disabled, read-only or inert');
258
+ if (!['input', 'textarea'].includes(tag) && !this.isContentEditable) throw new Error('Ref is not an editable field');
259
+ if (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(this.type)) throw new Error('Input type cannot be filled with text');
260
+ if (mode === 'dom') {
261
+ const win = this.ownerDocument.defaultView;
262
+ if (this.isContentEditable) this.textContent = text;
263
+ else {
264
+ const prototype = tag === 'textarea' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype;
265
+ Object.getOwnPropertyDescriptor(prototype, 'value').set.call(this, text);
266
+ }
267
+ this.dispatchEvent(new win.InputEvent('input', { bubbles: true, composed: true, inputType: 'insertReplacementText', data: text }));
268
+ this.dispatchEvent(new win.Event('change', { bubbles: true }));
269
+ } else {
270
+ this.focus();
271
+ const active = this.getRootNode().activeElement;
272
+ if (active !== this && !this.contains(active)) throw new Error('Field did not receive focus');
273
+ if (this.isContentEditable) {
274
+ const range = this.ownerDocument.createRange(); range.selectNodeContents(this);
275
+ const selection = this.ownerDocument.getSelection(); selection.removeAllRanges(); selection.addRange(range);
276
+ } else this.select();
277
+ }
278
+ return true;
279
+ }
280
+
281
+ function verifyFill(text) {
282
+ // Native editing may represent line breaks using nested blocks or <br>.
283
+ return this.isConnected && (this.isContentEditable ? this.textContent === text || this.innerText === text : this.value === text);
284
+ }
285
+
286
+ function clickElement() {
287
+ const element = this.nodeType === 3 ? this.parentElement : this;
288
+ if (!element?.isConnected) throw new Error('Element is detached');
289
+ if (element.matches(':disabled') || element.closest('[inert]')) throw new Error('Element is disabled or inert');
290
+ const win = element.ownerDocument.defaultView, rect = element.getBoundingClientRect(), style = win.getComputedStyle(element);
291
+ if (style.visibility !== 'visible' || !rect.width || !rect.height) throw new Error('Element is not visible');
292
+ if (!(element instanceof win.HTMLElement)) throw new Error('DOM click supports HTML elements only; use native input for this ref.');
293
+ win.HTMLElement.prototype.click.call(element);
294
+ return { clicked: true };
295
+ }
296
+
297
+ function activeElement() {
298
+ let element = document.activeElement;
299
+ while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
300
+ return element;
301
+ }
302
+
303
+ export function selectOptions(args) {
304
+ if (['value', 'label', 'index'].filter(key => args[key] != null).length !== 1) throw new Error('Provide exactly one of value, label or index');
305
+ if (args.index != null && (!Number.isSafeInteger(args.index) || args.index < 0)) throw new Error('index must be a nonnegative safe integer');
306
+ for (const key of ['value', 'label']) if (args[key] != null && typeof args[key] !== 'string') throw new Error(`${key} must be a string`);
307
+ }
308
+
309
+ export function selectElement(choice, apply = true) {
310
+ if (this.tagName !== 'SELECT' || !this.isConnected) throw new Error('Target must be a connected HTML select');
311
+ for (let parent = this; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
312
+ if (parent.matches(':disabled') || parent.hasAttribute('inert')) throw new Error('Select is disabled or inert');
313
+ }
314
+ const options = Array.from(this.options);
315
+ const matches = options.filter((option, index) => choice.index != null ? index === choice.index : choice.value != null ? option.value === choice.value : option.label === choice.label);
316
+ if (matches.length !== 1) throw new Error(`Option must match exactly once; matched ${matches.length}. Use an explicit index for duplicate labels/values. No value was set.`);
317
+ const option = matches[0];
318
+ if (option.disabled || option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.disabled) throw new Error('Option is disabled; no value was set');
319
+ if (!apply) return true;
320
+ const win = this.ownerDocument.defaultView;
321
+ const index = options.indexOf(option);
322
+ Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'selectedIndex').set.call(this, index);
323
+ this.dispatchEvent(new win.Event('input', { bubbles: true, composed: true }));
324
+ this.dispatchEvent(new win.Event('change', { bubbles: true }));
325
+ if (!this.isConnected || this.selectedIndex !== index || this.options[index] !== option || !option.selected) throw new Error('Select did not retain the requested option. Outcome is unknown; observe before continuing.');
326
+ return { selected: true, index, value: option.value, label: option.label, value_verified: true };
327
+ }
328
+
329
+ export function typeOptions(args) {
330
+ if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
331
+ timingOption(args.delay_ms ?? 0, 'delay_ms');
332
+ }
333
+
334
+ export function characterParameters(character) {
335
+ if (typeof character !== 'string' || Array.from(character).length !== 1 || /[\r\n\t\x00-\x1f]/.test(character)) throw new Error('type accepts printable characters; use press_key for control keys');
336
+ try {
337
+ const result = keyParameters(/^[A-Z]$/.test(character) ? `Shift+${character}` : character);
338
+ return { params: { ...result.params, key: character }, text: character };
339
+ } catch {
340
+ return { params: { key: character, code: '', windowsVirtualKeyCode: 0, nativeVirtualKeyCode: 0, modifiers: 0 }, text: character };
341
+ }
342
+ }