@evan_zhong/browser-attach 0.21.0 → 0.22.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.
- package/README.md +1 -1
- package/bin/browser-attach.mjs +44 -43
- package/docs/ARCHITECTURE.md +18 -9
- package/docs/TEST-REPORT.md +31 -1
- package/docs/USAGE.md +18 -2
- package/extension/actionability.js +99 -95
- package/extension/click.js +143 -377
- package/extension/controller.js +430 -1018
- package/extension/diagnostics.js +167 -241
- package/extension/dialogs.js +73 -0
- package/extension/drag.js +195 -195
- package/extension/errors.js +18 -0
- package/extension/evaluate.js +133 -133
- package/extension/fetch-page.js +131 -0
- package/extension/input.js +344 -339
- package/extension/locator.js +43 -22
- package/extension/log-data.js +29 -0
- package/extension/manifest.json +1 -1
- package/extension/navigation.js +74 -74
- package/extension/network.js +79 -0
- package/extension/observation.js +232 -0
- package/extension/outcomes.js +71 -15
- package/extension/page-url.js +8 -0
- package/extension/pointer.js +243 -0
- package/extension/screenshot.js +64 -0
- package/extension/scroll.js +142 -42
- package/extension/text.js +4 -2
- package/extension/upload.js +57 -0
- package/extension/wait.js +119 -117
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -0
- package/src/client.mjs +50 -135
- package/src/config.mjs +1 -1
- package/src/hub-runtime.mjs +87 -0
- package/src/mcp.mjs +216 -215
package/extension/diagnostics.js
CHANGED
|
@@ -1,241 +1,167 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (!Number.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (method === 'Runtime.
|
|
62
|
-
|
|
63
|
-
if (method
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
: [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
} else if (method === 'Network.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const { kind } = args;
|
|
169
|
-
if (!['console', 'network'].includes(kind)) throw new Error('kind must be console or network');
|
|
170
|
-
if (kind === 'console' && args.url_contains != null) throw new Error('url_contains is only valid for kind=network');
|
|
171
|
-
if (kind === 'network' && args.level != null) throw new Error('level is only valid for kind=console');
|
|
172
|
-
if (args.level != null && !['log', 'debug', 'info', 'warning', 'error'].includes(args.level)) throw new Error('Invalid console level');
|
|
173
|
-
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');
|
|
174
|
-
return { ...state[kind].read(args, record => kind === 'console' ? !args.level || record.level === args.level
|
|
175
|
-
: !args.url_contains || record.url?.includes(args.url_contains)),
|
|
176
|
-
kind, ...(kind === 'network' ? { capture: state.capture, pending_bodies: state.pending.size } : {}), capture_started_at: state.started, scope: 'tab_and_iframes', retained_across_navigation: true };
|
|
177
|
-
}
|
|
178
|
-
function networkOptions(tabId, config = tabs.get(tabId)?.capture) {
|
|
179
|
-
return config?.enabled ? { maxTotalBufferSize: 2097152, maxResourceBufferSize: config.max_body_bytes, maxPostDataSize: config.max_body_bytes } : { maxTotalBufferSize: 0, maxResourceBufferSize: 0, maxPostDataSize: 0 };
|
|
180
|
-
}
|
|
181
|
-
async function configure(tabId, config, targets) {
|
|
182
|
-
const state = tabs.get(tabId);
|
|
183
|
-
if (!state || typeof config?.enabled !== 'boolean') throw new Error('capture requires enabled boolean');
|
|
184
|
-
const max = config.max_body_bytes ?? 65536;
|
|
185
|
-
if (!Number.isInteger(max) || max < 1024 || max > 262144) throw new Error('max_body_bytes must be 1024..262144');
|
|
186
|
-
const next = { enabled: config.enabled, max_body_bytes: max };
|
|
187
|
-
try { for (const target of targets) await cdp(target, 'Network.enable', networkOptions(tabId, next)); }
|
|
188
|
-
catch (error) { await Promise.allSettled(targets.map(target => cdp(target, 'Network.enable', networkOptions(tabId)))); throw error; }
|
|
189
|
-
state.capture = next; state.captureVersion++; state.detailed.clear();
|
|
190
|
-
}
|
|
191
|
-
async function readDetails(tabId, args, targets) {
|
|
192
|
-
if (args.capture != null) {
|
|
193
|
-
if (args.kind !== 'network') throw new Error('capture is only valid for network logs');
|
|
194
|
-
await configure(tabId, args.capture, targets);
|
|
195
|
-
}
|
|
196
|
-
const state = tabs.get(tabId);
|
|
197
|
-
if (args.kind === 'network' && state) await Promise.allSettled([...state.pending]);
|
|
198
|
-
return read(tabId, args);
|
|
199
|
-
}
|
|
200
|
-
return { start, event, read, readDetails, networkOptions, documentStatus(tabId, frame) {
|
|
201
|
-
const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frame.id]));
|
|
202
|
-
return frame.loaderId && response?.loaderId === frame.loaderId && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null;
|
|
203
|
-
}, failedDocument(tabId, frameId) {
|
|
204
|
-
const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frameId]));
|
|
205
|
-
return response?.error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? { ...response } : null;
|
|
206
|
-
}, networkStatus(tabId) {
|
|
207
|
-
const state = tabs.get(tabId);
|
|
208
|
-
if (!state) throw new Error('Tab control ended while waiting for network idle');
|
|
209
|
-
return { inflight: state.requests.size, idle_ms: Date.now() - state.networkActivity, tracking_overflow: state.requestsOverflow };
|
|
210
|
-
}, detach(tabId, sessionId) {
|
|
211
|
-
const state = tabs.get(tabId);
|
|
212
|
-
if (!state) return;
|
|
213
|
-
for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
|
|
214
|
-
for (const map of [state.contexts, state.requests, state.documentResponses, state.pendingDocumentExtraInfo]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
|
|
215
|
-
}, forget: tabId => tabs.delete(tabId) };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const SECRET = /^(authorization|proxy-authorization|cookie|set-cookie|password|passwd|access_token|refresh_token|token|secret|api[_-]?key)$/i;
|
|
219
|
-
export function redactHeaders(headers = {}) {
|
|
220
|
-
return Object.fromEntries(Object.entries(headers).slice(0, 200).map(([key, value]) => [key, SECRET.test(key) ? '[REDACTED]' : clip(value, 2000)]));
|
|
221
|
-
}
|
|
222
|
-
export function boundedBody(body, maxBytes) {
|
|
223
|
-
let text = String(body ?? ''), format = 'text';
|
|
224
|
-
function scrub(value, depth = 0) {
|
|
225
|
-
if (depth > 16) return '[DEPTH LIMIT]';
|
|
226
|
-
if (Array.isArray(value)) return value.map(item => scrub(item, depth + 1));
|
|
227
|
-
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SECRET.test(key) ? '[REDACTED]' : scrub(item, depth + 1)]));
|
|
228
|
-
return value;
|
|
229
|
-
}
|
|
230
|
-
try { text = JSON.stringify(scrub(JSON.parse(text))); format = 'json'; }
|
|
231
|
-
catch {
|
|
232
|
-
if (/^[^=\s&]+=/.test(text)) {
|
|
233
|
-
const params = new URLSearchParams(text);
|
|
234
|
-
for (const key of [...params.keys()]) if (SECRET.test(key)) params.set(key, '[REDACTED]');
|
|
235
|
-
text = params.toString(); format = 'form';
|
|
236
|
-
} else text = text.replace(/\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:[^\r\n]*/gi, '$1: [REDACTED]');
|
|
237
|
-
}
|
|
238
|
-
const bytes = new TextEncoder().encode(text), truncated = bytes.length > maxBytes;
|
|
239
|
-
const output = new TextDecoder().decode(bytes.slice(0, maxBytes), { stream: true });
|
|
240
|
-
return { text: output, format, truncated, bytes: new TextEncoder().encode(output).length };
|
|
241
|
-
}
|
|
1
|
+
import { clip, redactHeaders, boundedBody } from './log-data.js';
|
|
2
|
+
|
|
3
|
+
const MAX_ENTRIES = 500, MAX_CHARS = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function buffer() {
|
|
6
|
+
const entries = [];
|
|
7
|
+
let sequence = 0, chars = 0, droppedThrough = 0;
|
|
8
|
+
return {
|
|
9
|
+
add(entry) {
|
|
10
|
+
const record = { seq: ++sequence, ...entry };
|
|
11
|
+
const size = JSON.stringify(record).length;
|
|
12
|
+
entries.push({ record, size }); chars += size;
|
|
13
|
+
while (entries.length > MAX_ENTRIES || chars > MAX_CHARS) {
|
|
14
|
+
const removed = entries.shift(); chars -= removed.size; droppedThrough = removed.record.seq;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
read(args, matches) {
|
|
18
|
+
const after = args.after_seq ?? 0, limit = args.limit ?? 100, maxChars = args.max_chars ?? 24000;
|
|
19
|
+
if (!Number.isSafeInteger(after) || after < 0 || after > sequence) throw new Error('after_seq must be a sequence from this claim (0 starts reading)');
|
|
20
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error('limit must be 1..200');
|
|
21
|
+
if (!Number.isInteger(maxChars) || maxChars < 1000 || maxChars > 100000) throw new Error('max_chars must be 1000..100000');
|
|
22
|
+
const result = [];
|
|
23
|
+
let next = Math.max(after, droppedThrough), used = 2;
|
|
24
|
+
for (const { record, size } of entries) {
|
|
25
|
+
if (record.seq <= after) continue;
|
|
26
|
+
if (!matches(record)) { next = record.seq; continue; }
|
|
27
|
+
if (result.length >= limit || (used + size + 1 > maxChars && result.length)) break;
|
|
28
|
+
const output = size + used + 1 <= maxChars ? record : {
|
|
29
|
+
seq: record.seq, truncated: true, serialized_preview: JSON.stringify(record).slice(0, Math.floor((maxChars - 150) / 6)),
|
|
30
|
+
};
|
|
31
|
+
result.push(output); used += JSON.stringify(output).length + 1; next = record.seq;
|
|
32
|
+
}
|
|
33
|
+
return { entries: result, next_seq: next, latest_seq: sequence, has_more: next < sequence,
|
|
34
|
+
data_loss: after < droppedThrough, dropped_through_seq: droppedThrough };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remoteText(arg) {
|
|
40
|
+
if ('value' in arg) return clip(typeof arg.value === 'string' ? arg.value : JSON.stringify(arg.value));
|
|
41
|
+
if (arg.unserializableValue) return clip(arg.unserializableValue);
|
|
42
|
+
if (arg.preview?.properties?.length) return clip(`${arg.description || arg.type} {${arg.preview.properties.slice(0, 10)
|
|
43
|
+
.map(property => `${clip(property.name, 100)}: ${clip(property.value ?? property.type, 100)}`).join(', ')}}`);
|
|
44
|
+
return clip(arg.description || arg.type);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function location(frame = {}) {
|
|
48
|
+
return { url: clip(frame.url), line: frame.lineNumber, column: frame.columnNumber };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createDiagnostics(cdp) {
|
|
52
|
+
const tabs = new Map();
|
|
53
|
+
function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
|
|
54
|
+
function event(source, method, params, tracked) {
|
|
55
|
+
const state = tabs.get(source.tabId);
|
|
56
|
+
if (!state) return;
|
|
57
|
+
const origin = { session_id: source.sessionId ?? null };
|
|
58
|
+
const contextKey = JSON.stringify([source.sessionId, params.executionContextId ?? params.context?.id ?? params.exceptionDetails?.executionContextId]);
|
|
59
|
+
if (method === 'Runtime.executionContextCreated') state.contexts.set(contextKey, params.context.auxData?.frameId);
|
|
60
|
+
if (method === 'Runtime.executionContextDestroyed') state.contexts.delete(contextKey);
|
|
61
|
+
if (method === 'Runtime.executionContextsCleared') for (const key of state.contexts.keys()) if (JSON.parse(key)[0] === (source.sessionId ?? null)) state.contexts.delete(key);
|
|
62
|
+
const timestamp = params.timestamp ?? params.entry?.timestamp;
|
|
63
|
+
if ((method.startsWith('Runtime.') || method.startsWith('Log.')) && timestamp != null && timestamp < state.started) return;
|
|
64
|
+
if (method === 'Runtime.consoleAPICalled') {
|
|
65
|
+
if (params.executionContextId === 0) return;
|
|
66
|
+
const type = params.type;
|
|
67
|
+
const level = ['warning', 'warn'].includes(type) ? 'warning' : ['error', 'assert'].includes(type) ? 'error'
|
|
68
|
+
: ['debug', 'trace'].includes(type) ? 'debug' : type === 'info' ? 'info' : 'log';
|
|
69
|
+
state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: 'console', type, level,
|
|
70
|
+
text: clip((params.args || []).slice(0, 20).map(remoteText).join(' '), 8000),
|
|
71
|
+
location: location(params.stackTrace?.callFrames?.[0]), execution_context_id: params.executionContextId });
|
|
72
|
+
} else if (method === 'Runtime.exceptionThrown') {
|
|
73
|
+
const details = params.exceptionDetails || {};
|
|
74
|
+
state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: 'exception', type: 'exception', level: 'error',
|
|
75
|
+
text: clip(details.exception?.description || details.text, 8000), location: location(details),
|
|
76
|
+
stack: (details.stackTrace?.callFrames || []).slice(0, 8).map(frame => ({ function: clip(frame.functionName, 200), ...location(frame) })) });
|
|
77
|
+
} else if (method === 'Log.entryAdded') {
|
|
78
|
+
const entry = params.entry;
|
|
79
|
+
state.console.add({ ...origin, frame_id: state.contexts.get(contextKey), timestamp, source: clip(entry.source, 100), type: 'browser', level: clip(entry.level, 100),
|
|
80
|
+
text: clip(entry.text, 8000), location: location(entry) });
|
|
81
|
+
} else if (method.startsWith('Network.')) {
|
|
82
|
+
if (!tracked) return;
|
|
83
|
+
const id = params.requestId, { key } = tracked;
|
|
84
|
+
let request = method === 'Network.requestWillBeSent' ? tracked.previous : tracked.request;
|
|
85
|
+
const add = (event, extra = {}) => state.network.add({ ...origin, event, request_id: id, timestamp, ...request, ...extra });
|
|
86
|
+
if (method === 'Network.requestWillBeSent') {
|
|
87
|
+
if (params.redirectResponse) add('redirect', { ...(state.capture.enabled ? { headers: redactHeaders(params.redirectResponse.headers) } : {}), status: params.redirectResponse.status,
|
|
88
|
+
url: clip(params.redirectResponse.url), redirect_to: clip(params.request.url) });
|
|
89
|
+
request = tracked.request;
|
|
90
|
+
if (tracked.evicted) state.detailed.delete(tracked.evicted);
|
|
91
|
+
state.detailed.delete(key);
|
|
92
|
+
if (state.capture.enabled && ['XHR', 'Fetch'].includes(params.type)) state.detailed.add(key);
|
|
93
|
+
add('request', { wall_time: params.wallTime, ...(state.detailed.has(key) ? {
|
|
94
|
+
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' } : {}),
|
|
95
|
+
} : {}) });
|
|
96
|
+
} else if (method === 'Network.responseReceived') {
|
|
97
|
+
const response = params.response;
|
|
98
|
+
add('response', { ...(state.detailed.has(key) ? { headers: redactHeaders(response.headers) } : {}), url: clip(response.url), status: response.status, status_text: clip(response.statusText, 200),
|
|
99
|
+
mime_type: clip(response.mimeType, 200), protocol: clip(response.protocol, 50),
|
|
100
|
+
from_cache: !!(response.fromDiskCache || response.fromPrefetchCache), from_service_worker: !!response.fromServiceWorker });
|
|
101
|
+
} else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
|
|
102
|
+
const failed = method === 'Network.loadingFailed';
|
|
103
|
+
add(failed ? 'failed' : 'finished', failed ? { error: clip(params.errorText), canceled: !!params.canceled,
|
|
104
|
+
blocked_reason: clip(params.blockedReason) } : { encoded_data_length: params.encodedDataLength });
|
|
105
|
+
if (!failed && state.detailed.has(key) && state.capture.enabled && cdp) {
|
|
106
|
+
const version = state.captureVersion, requestInfo = request, maxBytes = state.capture.max_body_bytes;
|
|
107
|
+
if (requestInfo?.decodedBytes > maxBytes) add('body', { response_body_unavailable: 'decoded response exceeds capture byte limit', decoded_bytes: requestInfo.decodedBytes });
|
|
108
|
+
else if (state.pending.size >= 8) add('body', { response_body_unavailable: 'capture concurrency limit (8)' });
|
|
109
|
+
else {
|
|
110
|
+
let timer;
|
|
111
|
+
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); })])
|
|
112
|
+
.then(result => {
|
|
113
|
+
if (tabs.get(source.tabId) !== state || state.captureVersion !== version) return;
|
|
114
|
+
state.network.add({ ...origin, event: 'body', request_id: id, timestamp, ...requestInfo,
|
|
115
|
+
...(result.base64Encoded ? { response_body_unavailable: 'binary response omitted' } : { response_body: boundedBody(result.body, maxBytes) }) });
|
|
116
|
+
}).catch(error => {
|
|
117
|
+
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) });
|
|
118
|
+
}).finally(() => { clearTimeout(timer); state.pending.delete(task); });
|
|
119
|
+
state.pending.add(task);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
state.detailed.delete(key);
|
|
123
|
+
} else if (method === 'Network.requestServedFromCache') add('cache');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function read(tabId, args) {
|
|
127
|
+
const state = tabs.get(tabId);
|
|
128
|
+
if (!state) throw new Error('Diagnostics are unavailable; claim the tab again');
|
|
129
|
+
const { kind } = args;
|
|
130
|
+
if (!['console', 'network'].includes(kind)) throw new Error('kind must be console or network');
|
|
131
|
+
if (kind === 'console' && args.url_contains != null) throw new Error('url_contains is only valid for kind=network');
|
|
132
|
+
if (kind === 'network' && args.level != null) throw new Error('level is only valid for kind=console');
|
|
133
|
+
if (args.level != null && !['log', 'debug', 'info', 'warning', 'error'].includes(args.level)) throw new Error('Invalid console level');
|
|
134
|
+
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');
|
|
135
|
+
return { ...state[kind].read(args, record => kind === 'console' ? !args.level || record.level === args.level
|
|
136
|
+
: !args.url_contains || record.url?.includes(args.url_contains)),
|
|
137
|
+
kind, ...(kind === 'network' ? { capture: state.capture, pending_bodies: state.pending.size } : {}), capture_started_at: state.started, scope: 'tab_and_iframes', retained_across_navigation: true };
|
|
138
|
+
}
|
|
139
|
+
function networkOptions(tabId, config = tabs.get(tabId)?.capture) {
|
|
140
|
+
return config?.enabled ? { maxTotalBufferSize: 2097152, maxResourceBufferSize: config.max_body_bytes, maxPostDataSize: config.max_body_bytes } : { maxTotalBufferSize: 0, maxResourceBufferSize: 0, maxPostDataSize: 0 };
|
|
141
|
+
}
|
|
142
|
+
async function configure(tabId, config, targets) {
|
|
143
|
+
const state = tabs.get(tabId);
|
|
144
|
+
if (!state || typeof config?.enabled !== 'boolean') throw new Error('capture requires enabled boolean');
|
|
145
|
+
const max = config.max_body_bytes ?? 65536;
|
|
146
|
+
if (!Number.isInteger(max) || max < 1024 || max > 262144) throw new Error('max_body_bytes must be 1024..262144');
|
|
147
|
+
const next = { enabled: config.enabled, max_body_bytes: max };
|
|
148
|
+
try { for (const target of targets) await cdp(target, 'Network.enable', networkOptions(tabId, next)); }
|
|
149
|
+
catch (error) { await Promise.allSettled(targets.map(target => cdp(target, 'Network.enable', networkOptions(tabId)))); throw error; }
|
|
150
|
+
state.capture = next; state.captureVersion++; state.detailed.clear();
|
|
151
|
+
}
|
|
152
|
+
async function readDetails(tabId, args, targets) {
|
|
153
|
+
if (args.capture != null) {
|
|
154
|
+
if (args.kind !== 'network') throw new Error('capture is only valid for network logs');
|
|
155
|
+
await configure(tabId, args.capture, targets);
|
|
156
|
+
}
|
|
157
|
+
const state = tabs.get(tabId);
|
|
158
|
+
if (args.kind === 'network' && state) await Promise.allSettled([...state.pending]);
|
|
159
|
+
return read(tabId, args);
|
|
160
|
+
}
|
|
161
|
+
return { start, event, read, readDetails, networkOptions, detach(tabId, sessionId) {
|
|
162
|
+
const state = tabs.get(tabId);
|
|
163
|
+
if (!state) return;
|
|
164
|
+
for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
|
|
165
|
+
for (const key of state.contexts.keys()) if (JSON.parse(key)[0] === sessionId) state.contexts.delete(key);
|
|
166
|
+
}, forget: tabId => tabs.delete(tabId) };
|
|
167
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { boundedRead } from './outcomes.js';
|
|
2
|
+
|
|
3
|
+
export function createDialogs(sendCommand, cancelNavigation, invalidate, isControlled) {
|
|
4
|
+
const dialogs = new Map(), policies = new Map(), waiters = new Map();
|
|
5
|
+
|
|
6
|
+
async function send(target, method, params = {}) {
|
|
7
|
+
if (typeof target === 'number') target = { tabId: target };
|
|
8
|
+
const { tabId } = target;
|
|
9
|
+
if (!method.startsWith('Input.') && !['Runtime.callFunctionOn', 'Runtime.evaluate', 'Page.navigate', 'DOM.setFileInputFiles'].includes(method)) {
|
|
10
|
+
return await sendCommand(target, method, params);
|
|
11
|
+
}
|
|
12
|
+
// A page modal can prevent the CDP response from returning. Release the
|
|
13
|
+
// operation so the next request can observe and handle that same dialog.
|
|
14
|
+
let rejectDialog;
|
|
15
|
+
const dialog = new Promise((resolve, reject) => { rejectDialog = reject; });
|
|
16
|
+
const pending = waiters.get(tabId) || new Set();
|
|
17
|
+
waiters.set(tabId, pending); pending.add(rejectDialog);
|
|
18
|
+
try { return await Promise.race([sendCommand(target, method, params), dialog]); }
|
|
19
|
+
finally { pending.delete(rejectDialog); if (!pending.size) waiters.delete(tabId); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function willHandle(tabId) {
|
|
23
|
+
const policy = policies.get(tabId);
|
|
24
|
+
return !!policy && policy.policy !== 'ask' && policy.messages.length < 10;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function event(source, method, params) {
|
|
28
|
+
const tabId = source.tabId;
|
|
29
|
+
if (method === 'Page.javascriptDialogClosed') { dialogs.delete(tabId); return; }
|
|
30
|
+
if (method !== 'Page.javascriptDialogOpening') return;
|
|
31
|
+
const policy = policies.get(tabId), automatic = willHandle(tabId);
|
|
32
|
+
const opened = { sessionId: source.sessionId, type: params.type, message: params.message, default_prompt: params.defaultPrompt, default_button: null };
|
|
33
|
+
dialogs.set(tabId, opened);
|
|
34
|
+
invalidate(tabId, 'dialog_opened');
|
|
35
|
+
const message = { ...opened, policy: policy?.policy || 'ask', accepted: automatic ? policy.policy === 'accept' : null };
|
|
36
|
+
for (let item = policy; item; item = item.parent) item.messages.push(message);
|
|
37
|
+
if (automatic) {
|
|
38
|
+
const handling = send(source, 'Page.handleJavaScriptDialog', { accept: policy.policy === 'accept', ...(params.type === 'prompt' ? { promptText: params.defaultPrompt || '' } : {}) });
|
|
39
|
+
policy.handling = handling;
|
|
40
|
+
handling.then(() => {
|
|
41
|
+
message.handled = true;
|
|
42
|
+
if (dialogs.get(tabId) === opened) dialogs.delete(tabId);
|
|
43
|
+
if (params.type === 'beforeunload' && policy.policy === 'dismiss') cancelNavigation(tabId, new Error('Navigation cancelled by dismissed beforeunload'));
|
|
44
|
+
}, error => {
|
|
45
|
+
message.handled = false; message.error = error.message;
|
|
46
|
+
for (const reject of waiters.get(tabId) || []) reject(Object.assign(new Error(`Automatic dialog handling failed: ${error.message}. Outcome is unknown.`), { outcome_unknown: true }));
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
for (const reject of waiters.get(tabId) || []) reject(Object.assign(new Error('The action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'), { outcome_unknown: true }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handle(tabId, args) {
|
|
54
|
+
if (typeof args.accept !== 'boolean') throw new Error('accept must be a boolean');
|
|
55
|
+
const dialog = dialogs.get(tabId);
|
|
56
|
+
await send({ tabId, ...(dialog?.sessionId ? { sessionId: dialog.sessionId } : {}) }, 'Page.handleJavaScriptDialog', { accept: args.accept,
|
|
57
|
+
...(args.prompt_text != null ? { promptText: String(args.prompt_text) } : {}) });
|
|
58
|
+
dialogs.delete(tabId); invalidate(tabId, 'dialog_handled');
|
|
59
|
+
return { handled: true, dialog: dialog ?? null, accepted: args.accept, observe_again: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { send, event, handle, willHandle,
|
|
63
|
+
has: tabId => dialogs.has(tabId), get: tabId => dialogs.get(tabId),
|
|
64
|
+
forget(tabId) { dialogs.delete(tabId); policies.delete(tabId); },
|
|
65
|
+
enter(tabId, policy) {
|
|
66
|
+
if (policy != null && !['ask', 'accept', 'dismiss'].includes(policy)) throw new Error('dialog_policy must be ask, accept or dismiss');
|
|
67
|
+
const parent = policies.get(tabId), state = { policy: policy ?? parent?.policy ?? 'ask', messages: [], parent };
|
|
68
|
+
policies.set(tabId, state); return state;
|
|
69
|
+
},
|
|
70
|
+
async settle(state) { if (state.handling) await boundedRead(() => state.handling, 500); },
|
|
71
|
+
exit(tabId, state) { if (state.parent && isControlled(tabId)) policies.set(tabId, state.parent); else policies.delete(tabId); },
|
|
72
|
+
};
|
|
73
|
+
}
|