@evan_zhong/browser-attach 0.0.0-stage → 0.11.1

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 (44) hide show
  1. package/CONTRIBUTING.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -3
  4. package/bin/browser-attach.mjs +43 -0
  5. package/docs/ARCHITECTURE.md +131 -0
  6. package/docs/NOSIS-INTEGRATION.md +53 -0
  7. package/docs/TEST-REPORT.md +39 -0
  8. package/docs/USAGE.md +372 -0
  9. package/extension/actionability.js +66 -0
  10. package/extension/background.js +98 -0
  11. package/extension/batch.js +32 -0
  12. package/extension/click-visuals.js +82 -0
  13. package/extension/click.js +308 -0
  14. package/extension/controller.js +812 -0
  15. package/extension/diagnostics.js +205 -0
  16. package/extension/drag.js +163 -0
  17. package/extension/evaluate.js +131 -0
  18. package/extension/icons/BrowserAttach.png +0 -0
  19. package/extension/input.js +318 -0
  20. package/extension/limits.js +6 -0
  21. package/extension/locator.js +115 -0
  22. package/extension/manifest.json +34 -0
  23. package/extension/navigation.js +65 -0
  24. package/extension/options.html +6 -0
  25. package/extension/options.js +15 -0
  26. package/extension/outcomes.js +15 -0
  27. package/extension/popup.html +5 -0
  28. package/extension/popup.js +10 -0
  29. package/extension/scroll.js +29 -0
  30. package/extension/sessions.js +100 -0
  31. package/extension/snapshot.js +75 -0
  32. package/extension/text.js +37 -0
  33. package/extension/ui.css +12 -0
  34. package/extension/visuals.js +93 -0
  35. package/extension/wait.js +81 -0
  36. package/mcp-config.json +8 -0
  37. package/package.json +54 -6
  38. package/scripts/check-package.mjs +20 -0
  39. package/src/client.mjs +91 -0
  40. package/src/config.mjs +36 -0
  41. package/src/hub.mjs +197 -0
  42. package/src/mcp.mjs +179 -0
  43. package/src/setup.mjs +59 -0
  44. package/src/upload.mjs +15 -0
@@ -0,0 +1,205 @@
1
+ const MAX_ENTRIES = 500, MAX_CHARS = 1024 * 1024;
2
+ const clip = (value, limit = 2000) => {
3
+ const text = String(value ?? '');
4
+ return text.length > limit ? `${text.slice(0, limit - 13)}…[truncated]` : text;
5
+ };
6
+
7
+ function buffer() {
8
+ const entries = [];
9
+ let sequence = 0, chars = 0, droppedThrough = 0;
10
+ return {
11
+ add(entry) {
12
+ const record = { seq: ++sequence, ...entry };
13
+ const size = JSON.stringify(record).length;
14
+ entries.push({ record, size }); chars += size;
15
+ while (entries.length > MAX_ENTRIES || chars > MAX_CHARS) {
16
+ const removed = entries.shift(); chars -= removed.size; droppedThrough = removed.record.seq;
17
+ }
18
+ },
19
+ read(args, matches) {
20
+ const after = args.after_seq ?? 0, limit = args.limit ?? 100, maxChars = args.max_chars ?? 24000;
21
+ if (!Number.isSafeInteger(after) || after < 0 || after > sequence) throw new Error('after_seq must be a sequence from this claim (0 starts reading)');
22
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error('limit must be 1..200');
23
+ if (!Number.isInteger(maxChars) || maxChars < 1000 || maxChars > 100000) throw new Error('max_chars must be 1000..100000');
24
+ const result = [];
25
+ let next = Math.max(after, droppedThrough), used = 2;
26
+ for (const { record, size } of entries) {
27
+ if (record.seq <= after) continue;
28
+ if (!matches(record)) { next = record.seq; continue; }
29
+ if (result.length >= limit || (used + size + 1 > maxChars && result.length)) break;
30
+ const output = size + used + 1 <= maxChars ? record : {
31
+ seq: record.seq, truncated: true, serialized_preview: JSON.stringify(record).slice(0, Math.floor((maxChars - 150) / 6)),
32
+ };
33
+ result.push(output); used += JSON.stringify(output).length + 1; next = record.seq;
34
+ }
35
+ return { entries: result, next_seq: next, latest_seq: sequence, has_more: next < sequence,
36
+ data_loss: after < droppedThrough, dropped_through_seq: droppedThrough };
37
+ },
38
+ };
39
+ }
40
+
41
+ function remoteText(arg) {
42
+ if ('value' in arg) return clip(typeof arg.value === 'string' ? arg.value : JSON.stringify(arg.value));
43
+ if (arg.unserializableValue) return clip(arg.unserializableValue);
44
+ if (arg.preview?.properties?.length) return clip(`${arg.description || arg.type} {${arg.preview.properties.slice(0, 10)
45
+ .map(property => `${clip(property.name, 100)}: ${clip(property.value ?? property.type, 100)}`).join(', ')}}`);
46
+ return clip(arg.description || arg.type);
47
+ }
48
+
49
+ function location(frame = {}) {
50
+ return { url: clip(frame.url), line: frame.lineNumber, column: frame.columnNumber };
51
+ }
52
+
53
+ export function createDiagnostics(cdp) {
54
+ const tabs = new Map();
55
+ function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
56
+ function event(source, method, params) {
57
+ const state = tabs.get(source.tabId);
58
+ if (!state) return;
59
+ const origin = { session_id: source.sessionId ?? null };
60
+ const contextKey = JSON.stringify([source.sessionId, params.executionContextId ?? params.context?.id ?? params.exceptionDetails?.executionContextId]);
61
+ if (method === 'Runtime.executionContextCreated') state.contexts.set(contextKey, params.context.auxData?.frameId);
62
+ if (method === 'Runtime.executionContextDestroyed') state.contexts.delete(contextKey);
63
+ if (method === 'Runtime.executionContextsCleared') for (const key of state.contexts.keys()) if (JSON.parse(key)[0] === (source.sessionId ?? null)) state.contexts.delete(key);
64
+ const timestamp = params.timestamp ?? params.entry?.timestamp;
65
+ if ((method.startsWith('Runtime.') || method.startsWith('Log.')) && timestamp != null && timestamp < state.started) return;
66
+ if (method === 'Runtime.consoleAPICalled') {
67
+ if (params.executionContextId === 0) return;
68
+ const type = params.type;
69
+ const level = ['warning', 'warn'].includes(type) ? 'warning' : ['error', 'assert'].includes(type) ? 'error'
70
+ : ['debug', 'trace'].includes(type) ? 'debug' : type === 'info' ? 'info' : 'log';
71
+ state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: 'console', type, level,
72
+ text: clip((params.args || []).slice(0, 20).map(remoteText).join(' '), 8000),
73
+ location: location(params.stackTrace?.callFrames?.[0]), execution_context_id: params.executionContextId });
74
+ } else if (method === 'Runtime.exceptionThrown') {
75
+ const details = params.exceptionDetails || {};
76
+ state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: 'exception', type: 'exception', level: 'error',
77
+ text: clip(details.exception?.description || details.text, 8000), location: location(details),
78
+ stack: (details.stackTrace?.callFrames || []).slice(0, 8).map(frame => ({ function: clip(frame.functionName, 200), ...location(frame) })) });
79
+ } else if (method === 'Log.entryAdded') {
80
+ const entry = params.entry;
81
+ state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: clip(entry.source, 100), type: 'browser', level: clip(entry.level, 100),
82
+ text: clip(entry.text, 8000), location: location(entry) });
83
+ } else if (method.startsWith('Network.')) {
84
+ const id = params.requestId;
85
+ if (!id) return;
86
+ state.networkActivity = Date.now();
87
+ const key = JSON.stringify([source.sessionId, id]);
88
+ let request = state.requests.get(key);
89
+ const add = (event, extra = {}) => state.network.add({ ...origin, event, request_id: id, timestamp, ...request, ...extra });
90
+ if (method === 'Network.requestWillBeSent') {
91
+ if (params.redirectResponse) add('redirect', { ...(state.capture.enabled ? { headers: redactHeaders(params.redirectResponse.headers) } : {}), status: params.redirectResponse.status,
92
+ url: clip(params.redirectResponse.url), redirect_to: clip(params.request.url) });
93
+ request = { url: clip(params.request.url), method: clip(params.request.method, 50), resource_type: clip(params.type, 50),
94
+ frame_id: params.frameId, hop: request ? request.hop + 1 : 0 };
95
+ Object.defineProperty(request, 'decodedBytes', { value: 0, writable: true });
96
+ state.requests.delete(key); state.requests.set(key, request);
97
+ if (state.requests.size > MAX_ENTRIES) { state.requestsOverflow = true; const removed = state.requests.keys().next().value; state.requests.delete(removed); state.detailed.delete(removed); }
98
+ state.detailed.delete(key);
99
+ if (state.capture.enabled && ['XHR', 'Fetch'].includes(params.type)) state.detailed.add(key);
100
+ add('request', { wall_time: params.wallTime, ...(state.detailed.has(key) ? {
101
+ headers: redactHeaders(params.request.headers), ...(params.request.postData != null ? { request_body: boundedBody(params.request.postData, state.capture.max_body_bytes) } : params.request.hasPostData ? { request_body_unavailable: 'not present in bounded request event' } : {}),
102
+ } : {}) });
103
+ } else if (method === 'Network.dataReceived') {
104
+ if (request && Number.isFinite(params.dataLength) && params.dataLength > 0) request.decodedBytes += params.dataLength;
105
+ } else if (method === 'Network.responseReceived') {
106
+ const response = params.response;
107
+ add('response', { ...(state.detailed.has(key) ? { headers: redactHeaders(response.headers) } : {}), url: clip(response.url), status: response.status, status_text: clip(response.statusText, 200),
108
+ mime_type: clip(response.mimeType, 200), protocol: clip(response.protocol, 50),
109
+ from_cache: !!(response.fromDiskCache || response.fromPrefetchCache), from_service_worker: !!response.fromServiceWorker });
110
+ } else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
111
+ const failed = method === 'Network.loadingFailed';
112
+ add(failed ? 'failed' : 'finished', failed ? { error: clip(params.errorText), canceled: !!params.canceled,
113
+ blocked_reason: clip(params.blockedReason) } : { encoded_data_length: params.encodedDataLength });
114
+ if (!failed && state.detailed.has(key) && state.capture.enabled && cdp) {
115
+ const version = state.captureVersion, requestInfo = request, maxBytes = state.capture.max_body_bytes;
116
+ if (requestInfo?.decodedBytes > maxBytes) add('body', { response_body_unavailable: 'decoded response exceeds capture byte limit', decoded_bytes: requestInfo.decodedBytes });
117
+ else if (state.pending.size >= 8) add('body', { response_body_unavailable: 'capture concurrency limit (8)' });
118
+ else {
119
+ let timer;
120
+ const task = Promise.race([cdp(source, 'Network.getResponseBody', { requestId: id }), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('body read exceeded 1500 ms')), 1500); })])
121
+ .then(result => {
122
+ if (tabs.get(source.tabId) !== state || state.captureVersion !== version) return;
123
+ state.network.add({ ...origin, event: 'body', request_id: id, timestamp, ...requestInfo,
124
+ ...(result.base64Encoded ? { response_body_unavailable: 'binary response omitted' } : { response_body: boundedBody(result.body, maxBytes) }) });
125
+ }).catch(error => {
126
+ if (tabs.get(source.tabId) === state && state.captureVersion === version) state.network.add({ ...origin, event: 'body', request_id: id, timestamp, ...requestInfo, response_body_unavailable: clip(error.message, 500) });
127
+ }).finally(() => { clearTimeout(timer); state.pending.delete(task); });
128
+ state.pending.add(task);
129
+ }
130
+ }
131
+ state.requests.delete(key); state.detailed.delete(key);
132
+ } else if (method === 'Network.requestServedFromCache') add('cache');
133
+ }
134
+ }
135
+ function read(tabId, args) {
136
+ const state = tabs.get(tabId);
137
+ if (!state) throw new Error('Diagnostics are unavailable; claim the tab again');
138
+ const { kind } = args;
139
+ if (!['console', 'network'].includes(kind)) throw new Error('kind must be console or network');
140
+ if (kind === 'console' && args.url_contains != null) throw new Error('url_contains is only valid for kind=network');
141
+ if (kind === 'network' && args.level != null) throw new Error('level is only valid for kind=console');
142
+ if (args.level != null && !['log', 'debug', 'info', 'warning', 'error'].includes(args.level)) throw new Error('Invalid console level');
143
+ if (args.url_contains != null && (typeof args.url_contains !== 'string' || args.url_contains.length > 2000)) throw new Error('url_contains must be at most 2000 characters');
144
+ return { ...state[kind].read(args, record => kind === 'console' ? !args.level || record.level === args.level
145
+ : !args.url_contains || record.url?.includes(args.url_contains)),
146
+ kind, ...(kind === 'network' ? { capture: state.capture, pending_bodies: state.pending.size } : {}), capture_started_at: state.started, scope: 'tab_and_iframes', retained_across_navigation: true };
147
+ }
148
+ function networkOptions(tabId, config = tabs.get(tabId)?.capture) {
149
+ return config?.enabled ? { maxTotalBufferSize: 2097152, maxResourceBufferSize: config.max_body_bytes, maxPostDataSize: config.max_body_bytes } : { maxTotalBufferSize: 0, maxResourceBufferSize: 0, maxPostDataSize: 0 };
150
+ }
151
+ async function configure(tabId, config, targets) {
152
+ const state = tabs.get(tabId);
153
+ if (!state || typeof config?.enabled !== 'boolean') throw new Error('capture requires enabled boolean');
154
+ const max = config.max_body_bytes ?? 65536;
155
+ if (!Number.isInteger(max) || max < 1024 || max > 262144) throw new Error('max_body_bytes must be 1024..262144');
156
+ const next = { enabled: config.enabled, max_body_bytes: max };
157
+ try { for (const target of targets) await cdp(target, 'Network.enable', networkOptions(tabId, next)); }
158
+ catch (error) { await Promise.allSettled(targets.map(target => cdp(target, 'Network.enable', networkOptions(tabId)))); throw error; }
159
+ state.capture = next; state.captureVersion++; state.detailed.clear();
160
+ }
161
+ async function readDetails(tabId, args, targets) {
162
+ if (args.capture != null) {
163
+ if (args.kind !== 'network') throw new Error('capture is only valid for network logs');
164
+ await configure(tabId, args.capture, targets);
165
+ }
166
+ const state = tabs.get(tabId);
167
+ if (args.kind === 'network' && state) await Promise.allSettled([...state.pending]);
168
+ return read(tabId, args);
169
+ }
170
+ return { start, event, read, readDetails, networkOptions, networkStatus(tabId) {
171
+ const state = tabs.get(tabId);
172
+ if (!state) throw new Error('Tab control ended while waiting for network idle');
173
+ return { inflight: state.requests.size, idle_ms: Date.now() - state.networkActivity, tracking_overflow: state.requestsOverflow };
174
+ }, detach(tabId, sessionId) {
175
+ const state = tabs.get(tabId);
176
+ if (!state) return;
177
+ for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
178
+ for (const map of [state.contexts, state.requests]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
179
+ }, forget: tabId => tabs.delete(tabId) };
180
+ }
181
+
182
+ const SECRET = /^(authorization|proxy-authorization|cookie|set-cookie|password|passwd|access_token|refresh_token|token|secret|api[_-]?key)$/i;
183
+ export function redactHeaders(headers = {}) {
184
+ return Object.fromEntries(Object.entries(headers).slice(0, 200).map(([key, value]) => [key, SECRET.test(key) ? '[REDACTED]' : clip(value, 2000)]));
185
+ }
186
+ export function boundedBody(body, maxBytes) {
187
+ let text = String(body ?? ''), format = 'text';
188
+ function scrub(value, depth = 0) {
189
+ if (depth > 16) return '[DEPTH LIMIT]';
190
+ if (Array.isArray(value)) return value.map(item => scrub(item, depth + 1));
191
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SECRET.test(key) ? '[REDACTED]' : scrub(item, depth + 1)]));
192
+ return value;
193
+ }
194
+ try { text = JSON.stringify(scrub(JSON.parse(text))); format = 'json'; }
195
+ catch {
196
+ if (/^[^=\s&]+=/.test(text)) {
197
+ const params = new URLSearchParams(text);
198
+ for (const key of [...params.keys()]) if (SECRET.test(key)) params.set(key, '[REDACTED]');
199
+ text = params.toString(); format = 'form';
200
+ } else text = text.replace(/\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:[^\r\n]*/gi, '$1: [REDACTED]');
201
+ }
202
+ const bytes = new TextEncoder().encode(text), truncated = bytes.length > maxBytes;
203
+ const output = new TextDecoder().decode(bytes.slice(0, maxBytes), { stream: true });
204
+ return { text: output, format, truncated, bytes: new TextEncoder().encode(output).length };
205
+ }
@@ -0,0 +1,163 @@
1
+ import { deadline, delivery } from './actionability.js';
2
+ import { targetOptions } from './locator.js';
3
+
4
+ export function dragOptions(args) {
5
+ if (!args.source || !args.destination) throw new Error('drag requires source and destination');
6
+ for (const target of [args.source, args.destination]) targetOptions(target, { coordinates: true });
7
+ if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
8
+ if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
9
+ }
10
+
11
+ export function createDrag(cdp, value, mouse) {
12
+ const pending = new Map();
13
+ function event(tabId, method, params, sessionId) {
14
+ const operation = pending.get(tabId);
15
+ if (!operation) return;
16
+ if (['DOM.documentUpdated', 'Page.frameNavigated', 'Page.frameDetached', 'Page.navigatedWithinDocument'].includes(method)) {
17
+ const frameId = params.frame?.id || params.frameId;
18
+ if ([...operation.frames].some(key => { const [session, frame] = JSON.parse(key); return session === (sessionId ?? null) && (!frameId || frame === frameId); })) {
19
+ operation.error = new Error('Drag document/frame changed; outcome is unknown. Observe before continuing.');
20
+ operation.reject(operation.error);
21
+ }
22
+ }
23
+ if (method === 'Page.javascriptDialogOpening') { operation.error = new Error('Drag opened a JavaScript dialog; outcome is unknown. Observe and handle the dialog.'); operation.reject(operation.error); }
24
+ if (method === 'Input.dragIntercepted' && !sessionId) operation.intercept(params.data);
25
+ if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
26
+ try {
27
+ const { type, rejected } = JSON.parse(params.payload);
28
+ if (rejected) { operation.error = new Error('Drag target changed during dispatch; outcome is unknown. Observe before continuing.'); operation.reject(operation.error); }
29
+ operation.seen.add(type);
30
+ const required = operation.html5 ? ['mousedown', 'dragstart', 'drop', 'dragend'] : ['mousedown', 'mousemove', 'mouseup'];
31
+ if (required.every(type => operation.seen.has(type))) operation.resolve();
32
+ } catch (error) { operation.reject(error); }
33
+ }
34
+ function cancel(tabId) { const operation = pending.get(tabId); if (operation) { operation.error = new Error('Tab detached during drag; outcome is unknown.'); operation.reject(operation.error); } }
35
+ async function drag(tabId, args, resolve, assertCurrent = () => {}) {
36
+ dragOptions(args);
37
+ const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
38
+ await mouse.assertReady(tabId, budget);
39
+ const source = await mouse.prepare(tabId, await resolve(args.source, budget), { ...args.source, budget });
40
+ const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
41
+ let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
42
+ const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, frames, html5, seen: new Set(), resolve, reject, intercept }));
43
+ receipt.catch(() => {});
44
+ let pressed = false, releaseQueued = false, interceptEnabled = false, point = source.point;
45
+ const check = () => { assertCurrent(); const error = pending.get(tabId)?.error; if (error) throw error; };
46
+ const send = (method, params) => budget.run(() => {
47
+ check();
48
+ if (method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed') pressed = true;
49
+ if (method === 'Input.dispatchMouseEvent' && params.type === 'mouseReleased') releaseQueued = true;
50
+ return delivery(cdp(tabId, method, params));
51
+ });
52
+ const move = p => send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...p, button: pressed ? 'left' : 'none', buttons: pressed ? 1 : 0 });
53
+ async function guard(prepared, stage) {
54
+ for (const item of [prepared, ...prepared.boundaries]) {
55
+ const { target, frameId, backendNodeId } = item;
56
+ frames.add(JSON.stringify([target.sessionId ?? null, frameId]));
57
+ check();
58
+ const call = (method, params) => budget.run(() => cdp(target, method, params));
59
+ const { executionContextId } = await call('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' });
60
+ contexts.add(JSON.stringify([target.sessionId, executionContextId]));
61
+ await call('Runtime.addBinding', { name, executionContextId });
62
+ const isolated = await call('DOM.resolveNode', { backendNodeId, executionContextId, objectGroup: 'browser-attach' });
63
+ const installed = await call('Runtime.callFunctionOn', { objectId: isolated.object.objectId, functionDeclaration: installDragReceipt.toString(),
64
+ arguments: [{ value: name }, { value: item === prepared ? stage : `${stage}_boundary` }, { value: html5 }, { value: budget.remaining() + 500 }], objectGroup: 'browser-attach' });
65
+ value(installed);
66
+ if (!installed.result?.objectId) throw new Error('Could not install drag receipt; observe before continuing');
67
+ guards.push({ target, objectId: installed.result.objectId });
68
+ }
69
+ }
70
+ try {
71
+ await guard(source, 'source');
72
+ if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
73
+ await mouse.assertReady(tabId, budget);
74
+ budget.dispatch();
75
+ await move(point);
76
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', buttons: 1, clickCount: 1 });
77
+ // Cross Chromium's drag threshold before scrolling to an offscreen destination.
78
+ const initial = { x: point.x + (point.x > 20 ? -12 : 12), y: point.y };
79
+ await move(initial); point = initial;
80
+ const data = html5 ? await budget.run(() => delivery(intercepted)) : null;
81
+ // Preparation scrolls the destination into view while the button remains held,
82
+ // including nested containers, then rechecks frame boundaries and occlusion.
83
+ const destination = await mouse.prepare(tabId, await resolve(args.destination, budget), { ...args.destination, budget });
84
+ await guard(destination, 'destination');
85
+ const from = point;
86
+ if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
87
+ for (let index = 1; index <= steps; index++) {
88
+ point = { x: from.x + (destination.point.x - from.x) * index / steps, y: from.y + (destination.point.y - from.y) * index / steps };
89
+ if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
90
+ else await move(point);
91
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, 16)));
92
+ }
93
+ if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
94
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
95
+ pressed = false;
96
+ await budget.run(() => delivery(receipt));
97
+ return { dragged: true, method: html5 ? 'native_html5' : 'native_pointer', events_trusted: true, delivery_verified: true,
98
+ steps, from: source.point, to: point, application_result: 'not_checked', observe_again: true };
99
+ } finally {
100
+ pending.delete(tabId);
101
+ // Cancel/release only this gesture; cleanup is never a replay of the action.
102
+ const cleanup = [];
103
+ if (pressed && !releaseQueued) {
104
+ if (html5) cleanup.push(cdp(tabId, 'Input.cancelDragging'));
105
+ cleanup.push(cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 }));
106
+ }
107
+ if (interceptEnabled) cleanup.push(cdp(tabId, 'Input.setInterceptDrags', { enabled: false }));
108
+ for (const { target, objectId } of guards) {
109
+ cleanup.push(cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' }));
110
+ cleanup.push(cdp(target, 'Runtime.removeBinding', { name }));
111
+ }
112
+ await delivery(Promise.allSettled(cleanup), 250).catch(() => {});
113
+ }
114
+ }
115
+ return { drag, event, cancel };
116
+ }
117
+
118
+ function installDragReceipt(name, stage, html5, expiryMs) {
119
+ const target = this, win = target.ownerDocument.defaultView, report = win[name];
120
+ const types = stage === 'source' ? ['mousedown', 'mousemove', 'mouseup', 'dragstart', 'dragend']
121
+ : stage === 'source_boundary' ? ['mousedown'] : ['drop', 'mouseup'];
122
+ const listeners = [];
123
+ function listener(event, expected) {
124
+ if (!event.isTrusted) return;
125
+ const hit = event.composedPath().includes(expected);
126
+ if ((event.type === 'mousedown' || event.type === 'drop') && (!hit || !target.isConnected)) {
127
+ event.preventDefault(); event.stopImmediatePropagation(); report(JSON.stringify({ rejected: true })); return;
128
+ }
129
+ if (stage.endsWith('_boundary') || expected !== target) return;
130
+ if (['dragstart', 'dragend'].includes(event.type) && !hit) return;
131
+ if (event.type === 'mousemove' && event.buttons !== 1) return;
132
+ if (html5 && !['mousedown', 'dragstart', 'drop', 'dragend'].includes(event.type)) return;
133
+ report(JSON.stringify({ type: event.type }));
134
+ }
135
+ let expected = target;
136
+ while (true) {
137
+ const root = expected.getRootNode(), receiver = root.host ? root : win;
138
+ const local = expected, callback = event => listener(event, local);
139
+ listeners.push({ receiver, callback, types });
140
+ for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
141
+ if (!root.host) break;
142
+ expected = root.host;
143
+ }
144
+ if (stage === 'source' && html5) {
145
+ const callback = event => listener(event, target), types = ['dragend'];
146
+ listeners.push({ receiver: target, callback, types });
147
+ target.addEventListener('dragend', callback, true);
148
+ }
149
+ // Pointer capture and movement can leave the source's closed shadow root.
150
+ if (stage === 'source' && target.getRootNode().host && !html5) {
151
+ const callback = event => listener(event, target), types = ['mousemove', 'mouseup'];
152
+ listeners.push({ receiver: win, callback, types });
153
+ for (const type of types) win.addEventListener(type, callback, { capture: true, passive: false });
154
+ }
155
+ let timer;
156
+ function stop() {
157
+ clearTimeout(timer);
158
+ for (const { receiver, callback, types } of listeners) for (const type of types) receiver.removeEventListener(type, callback, true);
159
+ delete win[name];
160
+ }
161
+ timer = win.setTimeout(stop, expiryMs);
162
+ return { stop };
163
+ }
@@ -0,0 +1,131 @@
1
+ // Deliberately a page expression API, not a raw CDP transport.
2
+ const CAPTURE_CHARS = 1000000, CAPTURE_NODES = 50000;
3
+ async function evaluatePage(cdp, tabId, args) {
4
+ const timeout = args.timeout_ms ?? 10000, maxChars = args.max_chars ?? 20000;
5
+ if (typeof args.expression !== 'string' || !args.expression.trim() || args.expression.length > 100000)
6
+ throw new Error('expression must contain 1..100000 characters of JavaScript');
7
+ if (!Number.isInteger(timeout) || timeout < 100 || timeout > 30000) throw new Error('timeout_ms must be 100..30000');
8
+ if (!Number.isInteger(maxChars) || maxChars < 1 || maxChars > 100000) throw new Error('max_chars must be 1..100000');
9
+ let timer;
10
+ try {
11
+ const response = await Promise.race([
12
+ cdp(tabId, 'Runtime.evaluate', {
13
+ expression: `(async () => { let result = await (\n${args.expression}\n); if (typeof result === "function") result = await result(); return ${args.result_type === 'element' ? 'result' : `(${serializeEvaluation.toString()})(result, ${CAPTURE_CHARS}, ${CAPTURE_NODES})`}; })()`,
14
+ ...(args.result_type === 'element' ? { objectGroup: 'browser-attach-evaluation' } : {}), ...(args.context_id != null ? { contextId: args.context_id } : {}), awaitPromise: true, returnByValue: args.result_type !== 'element', userGesture: false, timeout,
15
+ }),
16
+ new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(
17
+ 'JavaScript evaluation timed out. It may still run or have changed the page; observe before continuing. Do not replay automatically.'
18
+ )), timeout); }),
19
+ ]);
20
+ if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text);
21
+ if (args.result_type === 'element') {
22
+ if (response.result?.subtype !== 'node' || !response.result.objectId) throw new Error('Element result must be one connected DOM element, not JSON/array/null');
23
+ return { element: response.result };
24
+ }
25
+ if (!response.result?.value || typeof response.result.value.truncated !== 'boolean') throw new Error('Invalid evaluation result');
26
+ return { ...response.result.value, scope: args.frame || args.frame_id ? 'frame' : 'main_frame', ...(args.evaluation_frame ? { frame_id: args.evaluation_frame } : {}), observe_again: true };
27
+ } catch (error) {
28
+ throw new Error(`Evaluation failed; page effects may already have occurred. ${String(error.message).slice(0, 12000)}`);
29
+ } finally { clearTimeout(timer); }
30
+ }
31
+
32
+ // Runs in the page. Bound traversal before transferring the result across CDP.
33
+ // Preserve ordinary JSON; explicitly mark values that JSON cannot represent.
34
+ export function serializeEvaluation(value, maxChars, maxNodes = 1000) {
35
+ const seen = new WeakSet();
36
+ let nodes = 0, chars = 0, truncated = false;
37
+ const mark = type => ({ $type: type });
38
+ function visit(item, depth) {
39
+ if (++nodes > maxNodes || depth > 8 || chars > maxChars) { truncated = true; return mark('truncated'); }
40
+ if (item === null || typeof item === 'boolean') return item;
41
+ if (typeof item === 'number') return Number.isFinite(item) && !Object.is(item, -0) ? item : { $type: 'number', value: String(Object.is(item, -0) ? '-0' : item) };
42
+ if (typeof item === 'string') {
43
+ const text = item.slice(0, Math.max(0, maxChars - chars));
44
+ chars += text.length;
45
+ if (text.length !== item.length) truncated = true;
46
+ return text;
47
+ }
48
+ if (typeof item === 'bigint') return { $type: 'bigint', value: visit(String(item), depth + 1) };
49
+ if (typeof item !== 'object') return mark(typeof item);
50
+ if (seen.has(item)) return mark('circular_or_repeated');
51
+ seen.add(item);
52
+ if (item instanceof Date) return { $type: 'date', value: String(item) };
53
+ if (item instanceof Error) return { $type: 'error', name: visit(item.name, depth + 1), message: visit(item.message, depth + 1) };
54
+ if (Array.isArray(item)) {
55
+ const output = [];
56
+ // Do not copy a sparse index into an array with an unbounded length.
57
+ for (let i = 0; i < item.length && nodes < maxNodes && chars < maxChars && depth < 8; i++) {
58
+ const property = Object.getOwnPropertyDescriptor(item, String(i));
59
+ output.push(!property ? visit(undefined, depth + 1) : 'value' in property ? visit(property.value, depth + 1) : (nodes++, mark('accessor')));
60
+ }
61
+ if (output.length < item.length) truncated = true;
62
+ return output;
63
+ }
64
+ const output = Object.create(null);
65
+ for (const key in item) {
66
+ if (!Object.prototype.hasOwnProperty.call(item, key)) continue;
67
+ if (nodes >= maxNodes || chars + key.length > maxChars || depth >= 8) { truncated = true; break; }
68
+ chars += key.length;
69
+ const property = Object.getOwnPropertyDescriptor(item, key);
70
+ const result = property && 'value' in property ? visit(property.value, depth + 1) : (nodes++, mark('accessor'));
71
+ Object.defineProperty(output, key, { value: result, enumerable: true, configurable: true, writable: true });
72
+ }
73
+ return output;
74
+ }
75
+ const result = visit(value, 0);
76
+ const json = JSON.stringify(result);
77
+ // Escaped strings and property names also count toward the transfer budget.
78
+ // Never emit half a serialized JSON document. Keep the payload structured,
79
+ // and state explicitly when the capture budget lost information.
80
+ if (json.length > maxChars) return { value: { $type: 'truncated', reason: 'serialized_size', required_chars: json.length }, truncated: true };
81
+ return { value: result, truncated };
82
+ }
83
+
84
+ // One bounded immutable result per claimed tab. Continuations never execute JS.
85
+ export function createEvaluations(cdp) {
86
+ const results = new Map();
87
+ async function read(tabId, args) {
88
+ const max = args.max_chars ?? 20000;
89
+ if (!Number.isInteger(max) || max < 1 || max > 100000) throw new Error('max_chars must be 1..100000');
90
+ let state;
91
+ if (args.cursor != null) {
92
+ state = results.get(tabId);
93
+ if (!state || state.cursor !== args.cursor) throw new Error('Evaluation cursor is stale or unknown; results expire on navigation, release or a new evaluation.');
94
+ if (args.expression != null) throw new Error('Provide cursor or expression, never both');
95
+ } else {
96
+ // Navigation/detach may arrive while the CDP promise is pending. A late
97
+ // reply must not resurrect cached page data after cleanup.
98
+ const pending = { frameId: args.evaluation_frame };
99
+ results.set(tabId, pending);
100
+ let result;
101
+ try { result = await evaluatePage(cdp, args.target || tabId, args); }
102
+ catch (error) { if (results.get(tabId) === pending) results.delete(tabId); throw error; }
103
+ if (results.get(tabId) !== pending) throw new Error('Document or claim changed during evaluation; effects may already have occurred. Result was discarded. Observe before continuing; do not replay automatically.');
104
+ if (result.element) { results.delete(tabId); return result; }
105
+ const kind = Array.isArray(result.value) ? 'array' : typeof result.value === 'string' ? 'string' :
106
+ result.value && typeof result.value === 'object' ? 'object' : 'scalar';
107
+ state = Object.assign(pending, { result, kind, offset: 0, token: crypto.randomUUID(), cursor: null,
108
+ items: kind === 'object' ? Object.entries(result.value) : result.value });
109
+ }
110
+ const { kind, items, offset, result } = state;
111
+ const length = kind === 'scalar' ? 1 : items.length;
112
+ const make = end => kind === 'string' ? items.slice(offset, end) : kind === 'array' ? items.slice(offset, end) :
113
+ kind === 'object' ? Object.fromEntries(items.slice(offset, end)) : items;
114
+ let low = offset, high = length;
115
+ while (low < high) {
116
+ const mid = Math.ceil((low + high) / 2);
117
+ if (JSON.stringify(make(mid)).length <= max) low = mid; else high = mid - 1;
118
+ }
119
+ if ((length > offset && low === offset) || JSON.stringify(make(low)).length > max) {
120
+ state.cursor ??= `${state.token}@${offset}`;
121
+ return { scope: result.scope, ...(result.frame_id ? { frame_id: result.frame_id } : {}), result_kind: kind, budget_too_small: true, next_cursor: state.cursor,
122
+ required_chars: JSON.stringify(make(Math.min(length, offset + 1))).length,
123
+ note: 'Increase max_chars (allowed 1..100000) and continue with this cursor. Select smaller fields if an individual item exceeds 100000 characters.' };
124
+ }
125
+ state.offset = low;
126
+ state.cursor = low < length ? `${state.token}@${low}` : null;
127
+ return { ...result, value: make(low), result_kind: kind, has_more: low < length,
128
+ next_cursor: state.cursor, capture_truncated: result.truncated, truncated: result.truncated || low < length };
129
+ }
130
+ return { read, forgetFrame(tabId, frameId) { if (results.get(tabId)?.frameId === frameId) results.delete(tabId); }, forget: tabId => results.delete(tabId) };
131
+ }