@evan_zhong/browser-attach 0.20.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.
@@ -0,0 +1,29 @@
1
+ export const clip = (value, limit = 2000) => {
2
+ const text = String(value ?? '');
3
+ return text.length > limit ? `${text.slice(0, limit - 13)}…[truncated]` : text;
4
+ };
5
+
6
+ const SECRET = /^(authorization|proxy-authorization|cookie|set-cookie|password|passwd|access_token|refresh_token|token|secret|api[_-]?key)$/i;
7
+ export function redactHeaders(headers = {}) {
8
+ return Object.fromEntries(Object.entries(headers).slice(0, 200).map(([key, value]) => [key, SECRET.test(key) ? '[REDACTED]' : clip(value, 2000)]));
9
+ }
10
+ export function boundedBody(body, maxBytes) {
11
+ let text = String(body ?? ''), format = 'text';
12
+ function scrub(value, depth = 0) {
13
+ if (depth > 16) return '[DEPTH LIMIT]';
14
+ if (Array.isArray(value)) return value.map(item => scrub(item, depth + 1));
15
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SECRET.test(key) ? '[REDACTED]' : scrub(item, depth + 1)]));
16
+ return value;
17
+ }
18
+ try { text = JSON.stringify(scrub(JSON.parse(text))); format = 'json'; }
19
+ catch {
20
+ if (/^[^=\s&]+=/.test(text)) {
21
+ const params = new URLSearchParams(text);
22
+ for (const key of [...params.keys()]) if (SECRET.test(key)) params.set(key, '[REDACTED]');
23
+ text = params.toString(); format = 'form';
24
+ } else text = text.replace(/\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:[^\r\n]*/gi, '$1: [REDACTED]');
25
+ }
26
+ const bytes = new TextEncoder().encode(text), truncated = bytes.length > maxBytes;
27
+ const output = new TextDecoder().decode(bytes.slice(0, maxBytes), { stream: true });
28
+ return { text: output, format, truncated, bytes: new TextEncoder().encode(output).length };
29
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -1,74 +1,74 @@
1
- import { NAVIGATION_MAX_TIMEOUT_MS } from './limits.js';
2
- import { pageUrl } from './page-url.js';
3
-
4
- // Lifecycle events can precede the Page.navigate reply. Keep them until its loader is known.
5
- export function createNavigation(cdp, validateUrl = pageUrl) {
6
- const pending = new Map();
7
- function event(tabId, method, params) { pending.get(tabId)?.event(method, params); }
8
- function cancel(tabId, reason) { pending.get(tabId)?.cancel(reason); }
9
- async function navigate(tabId, url, { wait_until = 'domcontentloaded', timeout_ms = 15000, direction } = {}) {
10
- if (!['commit', 'domcontentloaded', 'load'].includes(wait_until)) throw new Error('wait_until must be commit, domcontentloaded or load');
11
- if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > NAVIGATION_MAX_TIMEOUT_MS) throw new Error('timeout_ms must be 100..40000');
12
- const end = Date.now() + timeout_ms;
13
- async function prepare(operation) {
14
- const remaining = end - Date.now(); let timer;
15
- try {
16
- if (remaining <= 0) throw new Error('Navigation preparation timed out; no navigation was dispatched');
17
- return await Promise.race([operation(), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('Navigation preparation timed out; no navigation was dispatched')), remaining); })]);
18
- } finally { clearTimeout(timer); }
19
- }
20
- let entry, mainId;
21
- if (direction != null) {
22
- if (!['back', 'forward'].includes(direction) || url != null) throw new Error('Provide URL or direction=back/forward, never both');
23
- const history = await prepare(() => cdp(tabId, 'Page.getNavigationHistory'));
24
- entry = history.entries[history.currentIndex + (direction === 'back' ? -1 : 1)];
25
- if (!entry) throw new Error(`No ${direction} history entry; no navigation was dispatched`);
26
- await prepare(() => validateUrl(entry.url));
27
- mainId = (await prepare(() => cdp(tabId, 'Page.getFrameTree'))).frameTree.frame.id;
28
- } else url = await prepare(() => validateUrl(url));
29
- let result, currentDocument, completedDocument, resolveDone, rejectDone, timer;
30
- const events = [];
31
- const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
32
- function check() {
33
- if (!result) return;
34
- if (direction) {
35
- const commit = events.find(e => e.method === 'Page.frameNavigated' && e.frame.id === mainId && !e.frame.parentId || e.method === 'Page.navigatedWithinDocument' && e.frameId === mainId);
36
- if (!commit) return;
37
- const same = commit.method === 'Page.navigatedWithinDocument';
38
- if (same || wait_until === 'commit' || commit.type === 'BackForwardCacheRestore' || events.some(e => e.method === 'Page.lifecycleEvent' && e.frameId === mainId && e.loaderId === commit.frame?.loaderId && e.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
39
- result = { frameId: mainId, ...(same ? {} : { loaderId: commit.frame.loaderId || 'history' }) }; resolveDone(result);
40
- }
41
- return;
42
- }
43
- // A client-side redirect can replace the loader before the original reply.
44
- const current = currentDocument || result;
45
- if (wait_until === 'commit' || !current.loaderId || events.some(event =>
46
- event.frameId === current.frameId && event.loaderId === current.loaderId &&
47
- event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
48
- completedDocument ??= current;
49
- resolveDone(result);
50
- }
51
- }
52
- pending.set(tabId, {
53
- event(method, params) {
54
- if (!direction && method === 'Page.frameNavigated' && !params.frame.parentId && params.frame.loaderId) currentDocument = { frameId: params.frame.id, loaderId: params.frame.loaderId };
55
- if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); }
56
- },
57
- cancel(reason) { rejectDone(reason || new Error('Tab detached during navigation; outcome is unknown.')); },
58
- });
59
- timer = setTimeout(() => rejectDone(Object.assign(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`), { code: 'NAVIGATION_TIMEOUT' })), Math.max(0, end - Date.now()));
60
- try {
61
- // Attach rejection handlers immediately, including when the deadline wins first.
62
- const command = cdp(tabId, direction ? 'Page.navigateToHistoryEntry' : 'Page.navigate', direction ? { entryId: entry.id } : { url }).then(reply => {
63
- if (reply.errorText) throw Object.assign(new Error(`Navigation failed: ${reply.errorText}`), { code: 'NAVIGATION_FAILED', navigation_error: reply.errorText });
64
- if (reply.isDownload) throw new Error('Navigation started a download instead of a document.');
65
- result = direction ? {} : reply; check(); return done;
66
- });
67
- await Promise.race([command, done]);
68
- const document = completedDocument || result;
69
- return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId,
70
- document: { frameId: document.frameId, loaderId: document.loaderId } };
71
- } finally { clearTimeout(timer); pending.delete(tabId); }
72
- }
73
- return { event, cancel, navigate };
74
- }
1
+ import { NAVIGATION_MAX_TIMEOUT_MS } from './limits.js';
2
+ import { pageUrl } from './page-url.js';
3
+
4
+ // Lifecycle events can precede the Page.navigate reply. Keep them until its loader is known.
5
+ export function createNavigation(cdp, validateUrl = pageUrl) {
6
+ const pending = new Map();
7
+ function event(tabId, method, params) { pending.get(tabId)?.event(method, params); }
8
+ function cancel(tabId, reason) { pending.get(tabId)?.cancel(reason); }
9
+ async function navigate(tabId, url, { wait_until = 'domcontentloaded', timeout_ms = 15000, direction } = {}) {
10
+ if (!['commit', 'domcontentloaded', 'load'].includes(wait_until)) throw new Error('wait_until must be commit, domcontentloaded or load');
11
+ if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > NAVIGATION_MAX_TIMEOUT_MS) throw new Error('timeout_ms must be 100..40000');
12
+ const end = Date.now() + timeout_ms;
13
+ async function prepare(operation) {
14
+ const remaining = end - Date.now(); let timer;
15
+ try {
16
+ if (remaining <= 0) throw Object.assign(new Error('Navigation preparation timed out; no navigation was dispatched'), { input_dispatched: false });
17
+ return await Promise.race([operation(), new Promise((resolve, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('Navigation preparation timed out; no navigation was dispatched'), { input_dispatched: false })), remaining); })]);
18
+ } finally { clearTimeout(timer); }
19
+ }
20
+ let entry, mainId;
21
+ if (direction != null) {
22
+ if (!['back', 'forward'].includes(direction) || url != null) throw new Error('Provide URL or direction=back/forward, never both');
23
+ const history = await prepare(() => cdp(tabId, 'Page.getNavigationHistory'));
24
+ entry = history.entries[history.currentIndex + (direction === 'back' ? -1 : 1)];
25
+ if (!entry) throw new Error(`No ${direction} history entry; no navigation was dispatched`);
26
+ await prepare(() => validateUrl(entry.url));
27
+ mainId = (await prepare(() => cdp(tabId, 'Page.getFrameTree'))).frameTree.frame.id;
28
+ } else url = await prepare(() => validateUrl(url));
29
+ let result, currentDocument, completedDocument, resolveDone, rejectDone, timer;
30
+ const events = [];
31
+ const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
32
+ function check() {
33
+ if (!result) return;
34
+ if (direction) {
35
+ const commit = events.find(e => e.method === 'Page.frameNavigated' && e.frame.id === mainId && !e.frame.parentId || e.method === 'Page.navigatedWithinDocument' && e.frameId === mainId);
36
+ if (!commit) return;
37
+ const same = commit.method === 'Page.navigatedWithinDocument';
38
+ if (same || wait_until === 'commit' || commit.type === 'BackForwardCacheRestore' || events.some(e => e.method === 'Page.lifecycleEvent' && e.frameId === mainId && e.loaderId === commit.frame?.loaderId && e.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
39
+ result = { frameId: mainId, ...(same ? {} : { loaderId: commit.frame.loaderId || 'history' }) }; resolveDone(result);
40
+ }
41
+ return;
42
+ }
43
+ // A client-side redirect can replace the loader before the original reply.
44
+ const current = currentDocument || result;
45
+ if (wait_until === 'commit' || !current.loaderId || events.some(event =>
46
+ event.frameId === current.frameId && event.loaderId === current.loaderId &&
47
+ event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
48
+ completedDocument ??= current;
49
+ resolveDone(result);
50
+ }
51
+ }
52
+ pending.set(tabId, {
53
+ event(method, params) {
54
+ if (!direction && method === 'Page.frameNavigated' && !params.frame.parentId && params.frame.loaderId) currentDocument = { frameId: params.frame.id, loaderId: params.frame.loaderId };
55
+ if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); }
56
+ },
57
+ cancel(reason) { rejectDone(reason || Object.assign(new Error('Tab detached during navigation; outcome is unknown.'), { outcome_unknown: true })); },
58
+ });
59
+ timer = setTimeout(() => rejectDone(Object.assign(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`), { outcome_unknown: true, code: 'NAVIGATION_TIMEOUT' })), Math.max(0, end - Date.now()));
60
+ try {
61
+ // Attach rejection handlers immediately, including when the deadline wins first.
62
+ const command = cdp(tabId, direction ? 'Page.navigateToHistoryEntry' : 'Page.navigate', direction ? { entryId: entry.id } : { url }).then(reply => {
63
+ if (reply.errorText) throw Object.assign(new Error(`Navigation failed: ${reply.errorText}`), { code: 'NAVIGATION_FAILED', navigation_error: reply.errorText });
64
+ if (reply.isDownload) throw new Error('Navigation started a download instead of a document.');
65
+ result = direction ? {} : reply; check(); return done;
66
+ });
67
+ await Promise.race([command, done]);
68
+ const document = completedDocument || result;
69
+ return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId,
70
+ document: { frameId: document.frameId, loaderId: document.loaderId } };
71
+ } finally { clearTimeout(timer); pending.delete(tabId); }
72
+ }
73
+ return { event, cancel, navigate };
74
+ }
@@ -0,0 +1,79 @@
1
+ import { clip } from './log-data.js';
2
+
3
+ const MAX_ENTRIES = 500;
4
+
5
+ // Passive request/document facts are independent of diagnostic log retention.
6
+ export function createNetworkTracker() {
7
+ const tabs = new Map();
8
+ function start(tabId) {
9
+ tabs.set(tabId, { requests: new Map(), documentResponses: new Map(), pendingDocumentExtraInfo: new Map(), networkActivity: Date.now(), requestsOverflow: false });
10
+ }
11
+ function event(source, method, params) {
12
+ const state = tabs.get(source.tabId), id = params.requestId;
13
+ if (!state || !method.startsWith('Network.') || !id) return;
14
+ state.networkActivity = Date.now();
15
+ const key = JSON.stringify([source.sessionId, id]);
16
+ let request = state.requests.get(key), evicted;
17
+ const previous = request;
18
+ if (method === 'Network.requestWillBeSent') {
19
+ if (params.type === 'Document') state.documentResponses.delete(JSON.stringify([source.sessionId ?? null, params.frameId]));
20
+ request = { url: clip(params.request.url), method: clip(params.request.method, 50), resource_type: clip(params.type, 50),
21
+ frame_id: params.frameId, hop: request ? request.hop + 1 : 0 };
22
+ Object.defineProperty(request, 'decodedBytes', { value: 0, writable: true });
23
+ state.requests.delete(key); state.requests.set(key, request);
24
+ if (state.requests.size > MAX_ENTRIES) { state.requestsOverflow = true; const removed = state.requests.keys().next().value; state.requests.delete(removed); evicted = removed; }
25
+ } else if (method === 'Network.dataReceived') {
26
+ if (request && Number.isFinite(params.dataLength) && params.dataLength > 0) request.decodedBytes += params.dataLength;
27
+ } else if (method === 'Network.responseReceived') {
28
+ const response = params.response;
29
+ if (params.type === 'Document') {
30
+ const documentKey = JSON.stringify([source.sessionId ?? null, params.frameId]);
31
+ state.documentResponses.delete(documentKey);
32
+ const header = name => Object.entries(response.headers || {}).find(([key]) => key.toLowerCase() === name)?.[1];
33
+ const pendingKey = JSON.stringify([source.sessionId ?? null, id, response.status]);
34
+ const extra = state.pendingDocumentExtraInfo.get(pendingKey);
35
+ const length = extra?.length ?? header('content-length');
36
+ state.pendingDocumentExtraInfo.delete(pendingKey);
37
+ state.documentResponses.set(documentKey, { requestId: id, loaderId: params.loaderId, status: response.status, url: response.url,
38
+ contentType: response.mimeType || String(header('content-type') || '').split(';')[0] || null, empty_body: String(length ?? '').trim() === '0' });
39
+ if (state.documentResponses.size > MAX_ENTRIES) state.documentResponses.delete(state.documentResponses.keys().next().value);
40
+ }
41
+ } else if (method === 'Network.responseReceivedExtraInfo') {
42
+ // ExtraInfo can arrive on either side of responseReceived. Enrich only
43
+ // its matching status, never a redirect hop or a wire-level cache 304.
44
+ const length = Object.entries(params.headers || {}).find(([key]) => key.toLowerCase() === 'content-length')?.[1];
45
+ if (length != null) {
46
+ let matched = false;
47
+ for (const [documentKey, response] of state.documentResponses) if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && response.status === params.statusCode) {
48
+ response.empty_body = String(length).trim() === '0'; matched = true;
49
+ }
50
+ if (!matched && state.requests.get(key)?.resource_type === 'Document') {
51
+ state.pendingDocumentExtraInfo.set(JSON.stringify([source.sessionId ?? null, id, params.statusCode]), { length });
52
+ if (state.pendingDocumentExtraInfo.size > MAX_ENTRIES) state.pendingDocumentExtraInfo.delete(state.pendingDocumentExtraInfo.keys().next().value);
53
+ }
54
+ }
55
+ } else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
56
+ const failed = method === 'Network.loadingFailed';
57
+ if (failed) for (const [documentKey, response] of state.documentResponses) {
58
+ if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && (!response.error || params.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE')) response.error = params.errorText;
59
+ }
60
+ state.requests.delete(key);
61
+ }
62
+ return { key, request, previous, evicted };
63
+ }
64
+ return { start, event, documentStatus(tabId, frame) {
65
+ const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frame.id]));
66
+ return frame.loaderId && response?.loaderId === frame.loaderId && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null;
67
+ }, failedDocument(tabId, frameId) {
68
+ const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frameId]));
69
+ return response?.error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? { ...response } : null;
70
+ }, networkStatus(tabId) {
71
+ const state = tabs.get(tabId);
72
+ if (!state) throw new Error('Tab control ended while waiting for network idle');
73
+ return { inflight: state.requests.size, idle_ms: Date.now() - state.networkActivity, tracking_overflow: state.requestsOverflow };
74
+ }, detach(tabId, sessionId) {
75
+ const state = tabs.get(tabId);
76
+ if (!state) return;
77
+ for (const map of [state.requests, state.documentResponses, state.pendingDocumentExtraInfo]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
78
+ }, forget: tabId => tabs.delete(tabId) };
79
+ }
@@ -0,0 +1,232 @@
1
+ import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
2
+
3
+ // Observation identity is separate from debugger session ownership.
4
+ export function createObservations(cdp, value, sessions, viewport, currentClaim, changed) {
5
+ const snapshots = new Map(), retiredRefs = new Map(), documents = new Map();
6
+ const pageContexts = new Map(), frameStates = new Map();
7
+ function retireRef(tabId, ref, cause, frameId) {
8
+ let history = retiredRefs.get(tabId);
9
+ if (!history) retiredRefs.set(tabId, history = new Map());
10
+ history.set(ref, { cause, frame_id: frameId });
11
+ if (history.size > 1000) history.delete(history.keys().next().value);
12
+ }
13
+
14
+ function invalidateSnapshot(tabId, cause) {
15
+ const state = snapshots.get(tabId);
16
+ if (state) for (const [ref, node] of state.refs) retireRef(tabId, ref, cause, node.frameId);
17
+ snapshots.delete(tabId);
18
+ }
19
+
20
+ async function getObject(tabId, ref, budget) {
21
+ const refError = (cause, frameId, message) => {
22
+ const dispatched = budget.has_dispatched();
23
+ return Object.assign(new Error(`${message}. Take a new browser_snapshot. ${dispatched ? 'Input was already dispatched; outcome is unknown. Do not replay automatically.' : 'No input was dispatched.'}`), { outcome_unknown: true,
24
+ input_dispatched: dispatched, diagnostics: { reason: 'stale_ref', cause, ref, ...(frameId ? { frame_id: frameId } : {}) },
25
+ });
26
+ };
27
+ const node = snapshots.get(tabId)?.refs.get(ref);
28
+ if (!node) {
29
+ const failure = retiredRefs.get(tabId)?.get(ref);
30
+ throw refError(failure?.cause || 'unknown_ref', failure?.frame_id, 'Element ref is stale or unknown');
31
+ }
32
+ const target = sessions.target(tabId, node.frameId);
33
+ if (target.sessionId !== node.sessionId) throw refError('frame_session_changed', node.frameId, 'Frame session changed');
34
+ let resolved, nodes;
35
+ try {
36
+ resolved = await budget.run(() => cdp(target, 'DOM.resolveNode', { backendNodeId: node.backendNodeId, objectGroup: 'browser-attach' }));
37
+ if (!resolved.object?.objectId) throw refError('element_detached', node.frameId, 'Element no longer exists');
38
+ ({ nodes } = await budget.run(() => cdp(target, 'Accessibility.getPartialAXTree', { backendNodeId: node.backendNodeId, fetchRelatives: false })));
39
+ }
40
+ catch (error) {
41
+ const retired = retiredRefs.get(tabId)?.get(ref);
42
+ if (retired) throw refError(retired.cause, retired.frame_id, 'Element ref became stale during resolution');
43
+ if (/no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw refError('element_detached', node.frameId, 'Element is detached or no longer exists');
44
+ throw error;
45
+ }
46
+ if (snapshots.get(tabId)?.refs.get(ref) !== node) {
47
+ const retired = retiredRefs.get(tabId)?.get(ref);
48
+ throw refError(retired?.cause || 'unknown_ref', node.frameId, 'Element ref became stale during resolution');
49
+ }
50
+ const current = nodes?.find(item => item.backendDOMNodeId === node.backendNodeId);
51
+ if (!current || !!current.ignored !== !!node.ignored || current.role?.value !== node.role || String(current.name?.value || '') !== node.name) {
52
+ throw refError('identity_changed', node.frameId, 'Element ref identity changed (role/name or accessibility)');
53
+ }
54
+ return { ...node, target, objectId: resolved.object.objectId };
55
+ }
56
+
57
+ async function rememberElement(tabId, target, objectId, frameId, budget) {
58
+ const version = documents.get(tabId), claim = currentClaim(tabId), frameVersion = frameStates.get(tabId)?.versions.get(frameId);
59
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
60
+ const { node } = await send('DOM.describeNode', { objectId, depth: 0 });
61
+ if (node.nodeType !== 1) throw new Error('Result must be a DOM element');
62
+ const { nodes } = await send('Accessibility.getPartialAXTree', { backendNodeId: node.backendNodeId, fetchRelatives: false });
63
+ const ax = nodes?.find(item => item.backendDOMNodeId === node.backendNodeId);
64
+ if (!ax) throw new Error('Element accessibility identity is unavailable; observe again');
65
+ const connected = value(await send('Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { return this.isConnected; }', returnByValue: true }));
66
+ if (!connected) throw new Error('Element is detached; no ref was created');
67
+ if (documents.get(tabId) !== version || currentClaim(tabId) !== claim || frameStates.get(tabId)?.versions.get(frameId) !== frameVersion) throw new Error('Element document/frame/control changed; no ref was created');
68
+ let state = snapshots.get(tabId);
69
+ if (!state) {
70
+ state = { id: crypto.randomUUID().slice(0, 8), refs: new Map(), entries: [], invalidFrames: new Set(), totalNodes: 0, nextCursor: null, metadata: { omitted_frames: [] } };
71
+ snapshots.set(tabId, state);
72
+ }
73
+ if ((state.extraRefs || 0) >= 500) throw new Error('Element ref budget exhausted; take a new snapshot');
74
+ state.extraRefs = (state.extraRefs || 0) + 1;
75
+ const ref = `${state.id}:element:${state.extraRefs}`;
76
+ state.refs.set(ref, { backendNodeId: node.backendNodeId, frameId, sessionId: target.sessionId, role: ax.role?.value, name: String(ax.name?.value || ''), ignored: !!ax.ignored });
77
+ return { ref, frame_id: frameId, role: ax.role?.value, name: String(ax.name?.value || '') };
78
+ }
79
+
80
+ function invalidateFrame(tabId, frameId) {
81
+ const frames = frameStates.get(tabId);
82
+ if (!frames || !frameId) return;
83
+ const affected = new Set([frameId]);
84
+ // Descendant documents disappear with their parent, even without individual events.
85
+ let size;
86
+ do {
87
+ size = affected.size;
88
+ for (const [id, parent] of frames.parents) if (affected.has(parent)) affected.add(id);
89
+ } while (size !== affected.size);
90
+ const state = snapshots.get(tabId);
91
+ for (const id of affected) {
92
+ frames.versions.set(id, (frames.versions.get(id) || 0) + 1);
93
+ if (state) omitFrame(tabId, state, id, 'Frame navigated or detached after observation');
94
+ }
95
+ changed(tabId, affected);
96
+ }
97
+
98
+ function omitFrame(tabId, state, frameId, reason) {
99
+ if (state.invalidFrames.has(frameId)) return;
100
+ state.invalidFrames.add(frameId);
101
+ for (const [ref, node] of state.refs) if (node.frameId === frameId) {
102
+ retireRef(tabId, ref, 'frame_changed', frameId);
103
+ state.refs.delete(ref);
104
+ }
105
+ state.totalNodes -= state.entries.filter(entry => entry.frameId === frameId && entry.node).length;
106
+ state.metadata.omitted_frames.push({ frame_id: frameId, reason });
107
+ }
108
+
109
+ function expireCursor(tabId) {
110
+ const state = snapshots.get(tabId);
111
+ if (state) state.nextCursor = null;
112
+ }
113
+
114
+ async function snapshot(tabId, args = {}, budget) {
115
+ const send = (...params) => budget ? budget.run(() => cdp(...params)) : cdp(...params);
116
+ if (args.cursor != null) {
117
+ const state = snapshots.get(tabId);
118
+ if (!state) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
119
+ return snapshotPage(state, args);
120
+ }
121
+ const options = snapshotOptions(args);
122
+ invalidateSnapshot(tabId, 'snapshot_replaced');
123
+ const documentVersion = documents.get(tabId);
124
+ const versions = new Map(frameStates.get(tabId)?.versions);
125
+ await (budget ? budget.run(() => sessions.settle(tabId)) : sessions.settle(tabId));
126
+ for (const target of sessions.targets(tabId)) await send(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' });
127
+ const frames = await (budget ? budget.run(() => sessions.frames(tabId, send)) : sessions.frames(tabId, send));
128
+ const mainId = frameStates.get(tabId).mainId;
129
+ for (const frame of frames) if (frame.parentId) frameStates.get(tabId).parents.set(frame.id, frame.parentId);
130
+ const knownFrames = new Set(frames.map(frame => frame.id));
131
+ const token = crypto.randomUUID().slice(0, 8);
132
+ const refs = new Map(), entries = [], omittedFrames = [];
133
+ for (const frame of frames) {
134
+ let nodes;
135
+ let target;
136
+ try {
137
+ target = sessions.target(tabId, frame.id);
138
+ frameStates.get(tabId).sessions.set(frame.id, target.sessionId);
139
+ ({ nodes } = await send(target, 'Accessibility.getFullAXTree', { frameId: frame.id }));
140
+ }
141
+ catch (error) {
142
+ if (frame.id === mainId) throw error;
143
+ omittedFrames.push({ frame_id: frame.id, url: frame.url, reason: error.message });
144
+ continue;
145
+ }
146
+ // Report cross-process frames that are missing from Page.getFrameTree.
147
+ for (const node of nodes) {
148
+ if (!/^iframe$/i.test(node.role?.value || '') || !node.backendDOMNodeId) continue;
149
+ const { node: owner } = await send(target, 'DOM.describeNode', { backendNodeId: node.backendDOMNodeId, depth: 0 });
150
+ const childId = owner.frameId || owner.contentDocument?.frameId;
151
+ if (!childId) continue;
152
+ frameStates.get(tabId)?.parents.set(childId, frame.id);
153
+ sessions.rememberParent(tabId, childId, frame.id);
154
+ if (knownFrames.has(childId)) continue;
155
+ const attributes = owner.attributes || [], srcIndex = attributes.indexOf('src');
156
+ const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
157
+ knownFrames.add(childId); frames.push({ id: childId, url });
158
+ }
159
+ const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
160
+ if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
161
+ for (const entry of rendered) entries.push(entry);
162
+ }
163
+ const metadata = value(await send(tabId, 'Runtime.evaluate', {
164
+ expression: '({title: document.title, url: location.href})', returnByValue: true,
165
+ }));
166
+ const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
167
+ metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
168
+ if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
169
+ for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
170
+ omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
171
+ }
172
+ const page = snapshotPage(state, args);
173
+ snapshots.set(tabId, state);
174
+ return page;
175
+ }
176
+
177
+
178
+ function documentChanged(tabId, cause) {
179
+ documents.set(tabId, (documents.get(tabId) || 0) + 1);
180
+ invalidateSnapshot(tabId, cause);
181
+ changed(tabId);
182
+ }
183
+ function contextEvent(source, method, params) {
184
+ const contextKey = JSON.stringify([source.tabId, source.sessionId]);
185
+ if (method === 'Runtime.executionContextCreated' && params.context.auxData?.isDefault) pageContexts.set(JSON.stringify([source.tabId, source.sessionId, params.context.auxData.frameId]), params.context.id);
186
+ if (method === 'Runtime.executionContextDestroyed' || method === 'Runtime.executionContextsCleared') {
187
+ for (const [key, id] of pageContexts) if (key.startsWith(contextKey.slice(0, -1) + ',') && (method.endsWith('Cleared') || id === params.executionContextId)) pageContexts.delete(key);
188
+ }
189
+ }
190
+ function event(source, method, params) {
191
+ const frames = frameStates.get(source.tabId);
192
+ if (method === 'Page.frameAttached') frames?.parents.set(params.frameId, params.parentFrameId);
193
+ const mainNavigation = !source.sessionId && method === 'Page.frameNavigated' && params.frame && !params.frame.parentId;
194
+ if (mainNavigation && frames) {
195
+ frames.mainId = params.frame.id;
196
+ frames.parents.clear(); frames.versions.clear(); frames.sessions.clear();
197
+ }
198
+ if (method === 'Page.frameNavigated' && params.frame?.parentId) frames?.parents.set(params.frame.id, params.frame.parentId);
199
+ const sameDocumentMain = method === 'Page.navigatedWithinDocument' && params.frameId === frames?.mainId;
200
+ if ((!source.sessionId && method === 'DOM.documentUpdated') || mainNavigation || sameDocumentMain) {
201
+ documentChanged(source.tabId, sameDocumentMain ? 'same_document_navigation' : mainNavigation ? 'document_navigation' : 'document_updated');
202
+ } else if (source.sessionId && method === 'DOM.documentUpdated') {
203
+ for (const [frame, session] of frameStates.get(source.tabId)?.sessions || []) if (session === source.sessionId) invalidateFrame(source.tabId, frame);
204
+ } else if (method === 'Page.frameNavigated' || method === 'Page.navigatedWithinDocument' || method === 'Page.frameDetached') {
205
+ invalidateFrame(source.tabId, params.frame?.id || params.frameId);
206
+ }
207
+ }
208
+ function initialize(tabId, frameTree) {
209
+ const state = { mainId: frameTree.frame.id, parents: new Map(), versions: new Map(), sessions: new Map() };
210
+ const remember = tree => {
211
+ if (tree.frame.parentId) state.parents.set(tree.frame.id, tree.frame.parentId);
212
+ (tree.childFrames || []).forEach(remember);
213
+ };
214
+ remember(frameTree); frameStates.set(tabId, state);
215
+ }
216
+ function forget(tabId) {
217
+ snapshots.delete(tabId); retiredRefs.delete(tabId); documents.delete(tabId);
218
+ for (const key of pageContexts.keys()) if (JSON.parse(key)[0] === tabId) pageContexts.delete(key);
219
+ frameStates.delete(tabId);
220
+ }
221
+ return { snapshot, resolve: getObject, rememberElement, invalidate: invalidateSnapshot, expireCursor,
222
+ initialize, invalidateFrame, documentChanged, event, contextEvent, forget,
223
+ version: tabId => documents.get(tabId),
224
+ mainFrame: tabId => frameStates.get(tabId)?.mainId,
225
+ frameVersion: (tabId, frameId) => frameStates.get(tabId)?.versions.get(frameId),
226
+ refFrame: (tabId, ref) => snapshots.get(tabId)?.refs.get(ref)?.frameId,
227
+ context: (tabId, sessionId, frameId) => pageContexts.get(JSON.stringify([tabId, sessionId, frameId])),
228
+ detachSession(tabId, sessionId) {
229
+ for (const key of pageContexts.keys()) { const [tab, session] = JSON.parse(key); if (tab === tabId && session === sessionId) pageContexts.delete(key); }
230
+ },
231
+ };
232
+ }
@@ -1,15 +1,71 @@
1
- // Observation can confirm a dialog/navigation/mutation, never absence of delivery.
2
- export async function boundedRead(operation, ms = 300) {
3
- let timer;
4
- try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('observation did not respond')), ms); })]); }
5
- finally { clearTimeout(timer); }
6
- }
7
- export function installOutcomeObserver() {
8
- let mutations = 0;
9
- const observer = new MutationObserver(records => { mutations += records.length; });
10
- observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
11
- const startUrl = location.href;
12
- let expiry = setTimeout(() => observer.disconnect(), 55000);
13
- return { read() { mutations += observer.takeRecords().length; return { dom_changed: mutations > 0, mutation_records: mutations, start_url: startUrl, current_url: location.href }; },
14
- stop() { clearTimeout(expiry); observer.disconnect(); } };
15
- }
1
+ import { deadline } from './actionability.js';
2
+ import { resolveFrame } from './locator.js';
3
+
4
+ // Observation can confirm a dialog/navigation/mutation, never absence of delivery.
5
+ export async function boundedRead(operation, ms = 300) {
6
+ let timer;
7
+ try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('observation did not respond')), ms); })]); }
8
+ finally { clearTimeout(timer); }
9
+ }
10
+ export function installOutcomeObserver() {
11
+ let mutations = 0;
12
+ const observer = new MutationObserver(records => { mutations += records.length; });
13
+ observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
14
+ const startUrl = location.href;
15
+ let expiry = setTimeout(() => observer.disconnect(), 55000);
16
+ return { read() { mutations += observer.takeRecords().length; return { dom_changed: mutations > 0, mutation_records: mutations, start_url: startUrl, current_url: location.href }; },
17
+ stop() { clearTimeout(expiry); observer.disconnect(); } };
18
+ }
19
+
20
+ export function createOutcomes(cdp, value, sessions, observations, getTab, getDialog) {
21
+ async function begin(tabId, command, args) {
22
+ const version = observations.version(tabId), startTab = await getTab(tabId);
23
+ const watch = ['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file', 'evaluate', 'navigate'].includes(command) && !(command === 'evaluate' && args.cursor != null);
24
+ let observer, observationError, observationFrame = observations.mainFrame(tabId), observationTarget = { tabId }, observedVersion = observations.frameVersion(tabId, observationFrame);
25
+ const startObservation = async () => { if (!watch || observer || getDialog(tabId)) return; try {
26
+ observer = await boundedRead(async () => {
27
+ if (args.ref) observationFrame = observations.refFrame(tabId, args.ref) || observationFrame;
28
+ else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))), { wait: false });
29
+ observationTarget = sessions.target(tabId, observationFrame);
30
+ observedVersion = observations.frameVersion(tabId, observationFrame);
31
+ const { executionContextId } = await cdp(observationTarget, 'Page.createIsolatedWorld', { frameId: observationFrame, worldName: 'browser-attach-outcome' });
32
+ const result = await cdp(observationTarget, 'Runtime.evaluate', { contextId: executionContextId, expression: `(${installOutcomeObserver.toString()})()`, objectGroup: 'browser-attach-outcome' });
33
+ if (!result.result?.objectId) throw new Error('DOM outcome observer could not be installed');
34
+ return result.result.objectId;
35
+ }, Math.min(200, args.budget?.remaining() ?? 200));
36
+ } catch (error) { observationError = error.message; } };
37
+
38
+ async function enrich(error, messages) {
39
+ if (!watch || error.input_dispatched === false || !error.outcome_unknown) return false;
40
+ let current, dom, tabError, domError = observationError;
41
+ await Promise.allSettled([
42
+ boundedRead(() => getTab(tabId)).then(result => { if (result) current = result; else tabError = 'Tab is unavailable'; }, error => { tabError = error.message; }),
43
+ observer ? boundedRead(async () => {
44
+ const result = value(await cdp(observationTarget, 'Runtime.callFunctionOn', { objectId: observer, functionDeclaration: 'function() { return this.read(); }', returnByValue: true }));
45
+ if (typeof result?.dom_changed !== 'boolean') throw new Error('DOM outcome observation is unavailable');
46
+ return result;
47
+ }).then(result => { dom = result; }, error => { domError = error.message; }) : Promise.resolve(),
48
+ ]);
49
+ const frameChanged = observations.frameVersion(tabId, observationFrame) !== observedVersion;
50
+ const navigationObserved = observations.version(tabId) !== version || !!current && current.url !== startTab.url;
51
+ error.post_action = { delivery: 'unknown', frame_id: observationFrame, frame_navigation_observed: frameChanged, dialog: getDialog(tabId) || null, dialogs: messages,
52
+ navigation_observed: navigationObserved,
53
+ current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
54
+ observations: { tab: { available: !!current, ...(tabError ? { error: tabError } : {}) },
55
+ dom: { available: !!dom, ...(!dom ? { error: domError || 'DOM outcome observer is unavailable' } : {}) } },
56
+ assessment: !!getDialog(tabId) || messages.length || navigationObserved || frameChanged || dom?.dom_changed ? 'observable_effects'
57
+ : dom && current ? 'no_observable_effects' : 'observation_unavailable',
58
+ note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
59
+
60
+ return true;
61
+ }
62
+ async function stop() {
63
+ if (observer) await boundedRead(async () => {
64
+ await cdp(observationTarget, 'Runtime.callFunctionOn', { objectId: observer, functionDeclaration: 'function() { this.stop(); }' });
65
+ await cdp(observationTarget, 'Runtime.releaseObject', { objectId: observer });
66
+ }, 100).catch(() => {});
67
+ }
68
+ return { start: startObservation, enrich, stop };
69
+ }
70
+ return { begin };
71
+ }
@@ -5,3 +5,11 @@ export function pageUrl(url) {
5
5
  }
6
6
  return parsed.href;
7
7
  }
8
+
9
+ export function httpUrl(url) {
10
+ const parsed = new URL(url);
11
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
12
+ throw new Error('Only http(s) pages without URL credentials can be controlled');
13
+ }
14
+ return parsed.href;
15
+ }