@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.
@@ -1,3 +1,5 @@
1
+ import { checkedValue } from './errors.js';
2
+
1
3
  // Selectors are reusable queries, never identities inferred from old AX text.
2
4
  export function targetOptions(args, { coordinates = false, focused = false } = {}) {
3
5
  const hasRef = args.ref != null, hasSelector = args.selector != null;
@@ -19,19 +21,26 @@ export function targetOptions(args, { coordinates = false, focused = false } = {
19
21
 
20
22
  export function queryElement(selector, type = 'css', nth, waitForMatch = false) {
21
23
  if (nth != null && (!Number.isSafeInteger(nth) || nth < 0)) throw new Error('nth must be a nonnegative safe integer (zero-based)');
24
+ const failure = (message, reason, detail = {}) => Object.assign(new Error(message), {
25
+ diagnostics: { reason, selector_type: type, ...detail },
26
+ });
22
27
  let element, count, at;
23
- if (type === 'xpath') {
24
- const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
25
- count = result.snapshotLength; at = index => result.snapshotItem(index);
26
- } else {
27
- const normalize = text => text.replace(/\s+/g, ' ').trim();
28
- const elements = type === 'label'
29
- ? Array.from(document.querySelectorAll('input,textarea,select,button,meter,output,progress')).filter(element =>
30
- Array.from(element.labels || []).some(label => normalize(label.textContent || '') === normalize(selector)))
31
- : type === 'placeholder'
32
- ? Array.from(document.querySelectorAll('[placeholder]')).filter(element => element.getAttribute('placeholder') === selector)
33
- : document.querySelectorAll(selector);
34
- count = elements.length; at = index => elements[index];
28
+ try {
29
+ if (type === 'xpath') {
30
+ const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
31
+ count = result.snapshotLength; at = index => result.snapshotItem(index);
32
+ } else {
33
+ const normalize = text => text.replace(/\s+/g, ' ').trim();
34
+ const elements = type === 'label'
35
+ ? Array.from(document.querySelectorAll('input,textarea,select,button,meter,output,progress')).filter(element =>
36
+ Array.from(element.labels || []).some(label => normalize(label.textContent || '') === normalize(selector)))
37
+ : type === 'placeholder'
38
+ ? Array.from(document.querySelectorAll('[placeholder]')).filter(element => element.getAttribute('placeholder') === selector)
39
+ : document.querySelectorAll(selector);
40
+ count = elements.length; at = index => elements[index];
41
+ }
42
+ } catch (error) {
43
+ throw failure(error.message, 'selector_invalid');
35
44
  }
36
45
  if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
37
46
  if (nth == null && count !== 1) {
@@ -43,11 +52,12 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
43
52
  disabled: node.matches(':disabled') || !!node.closest('[inert], [aria-disabled="true"]'),
44
53
  visible: rect.width > 0 && rect.height > 0 && style.visibility === 'visible' };
45
54
  });
46
- throw new Error(`Selector must match exactly one element; matched ${count}. Scope the selector or provide an explicit zero-based nth. Candidates: ${JSON.stringify(candidates)}${count > candidates.length ? ` (${count - candidates.length} more omitted)` : ''}`);
55
+ throw failure(`Selector must match exactly one element; matched ${count}. Scope the selector or provide an explicit zero-based nth. Candidates: ${JSON.stringify(candidates)}${count > candidates.length ? ` (${count - candidates.length} more omitted)` : ''}`,
56
+ count ? 'selector_ambiguous' : 'selector_not_found', { matched: count, candidates, omitted_candidates: Math.max(0, count - candidates.length) });
47
57
  }
48
- if (nth != null && nth >= count) throw new Error(`Selector nth=${nth} is out of range; matched ${count}`);
58
+ if (nth != null && nth >= count) throw failure(`Selector nth=${nth} is out of range; matched ${count}`, 'selector_not_found', { matched: count, nth });
49
59
  element = at(nth ?? 0);
50
- if (element.nodeType !== 1) throw new Error('Selector must resolve to an element');
60
+ if (element.nodeType !== 1) throw failure('Selector must resolve to an element', 'selector_not_element');
51
61
  return element;
52
62
  }
53
63
 
@@ -66,22 +76,27 @@ export async function resolveSelector(cdp, value, sessions, tabId, args, budget,
66
76
  } else {
67
77
  const result = await send('Runtime.evaluate', { contextId: context.executionContextId, objectGroup: 'browser-attach',
68
78
  expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
69
- value(result);
79
+ await checkedValue(result, send, value);
70
80
  if (result.result?.subtype !== 'null') {
71
- if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
81
+ if (!result.result?.objectId) throw Object.assign(new Error('Selector did not resolve to an element'), {
82
+ diagnostics: { reason: 'selector_not_element', frame_id: frameId },
83
+ });
72
84
  const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
73
85
  backendNodeId = node.backendNodeId;
74
86
  }
75
87
  }
76
88
  if (backendNodeId != null) break;
77
89
  if (!wait) return null;
78
- budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`);
90
+ budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`,
91
+ { reason: 'selector_not_found', selector_type: args.selector_type ?? 'css', frame_id: frameId, ...(args.nth == null ? {} : { nth: args.nth }) });
79
92
  await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
80
93
  }
81
94
  // Hit-test refs resolve in the default world too. CDP forbids passing object
82
95
  // arguments between worlds, even when both wrappers refer to the same node.
83
96
  const resolved = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
84
- if (!resolved.object?.objectId) throw new Error('Selector target detached before resolution; no input was dispatched');
97
+ if (!resolved.object?.objectId) throw Object.assign(new Error('Selector target detached before resolution; no input was dispatched'), {
98
+ diagnostics: { reason: 'element_detached', frame_id: frameId },
99
+ });
85
100
  return { target, frameId, objectId: resolved.object.objectId, backendNodeId };
86
101
  }
87
102
 
@@ -98,7 +113,13 @@ export function matchRole(nodes, args) {
98
113
  for (let i = (node.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(node.childIds[i]));
99
114
  }
100
115
  const candidates = [...matches.values()];
101
- if (args.nth == null && candidates.length > 1) throw new Error(`Role selector must match exactly one element; matched ${candidates.length}. Provide name or an explicit zero-based nth. Candidates: ${JSON.stringify(candidates.slice(0, 10).map((node, nth) => ({ nth, role: node.role?.value, name: String(node.name?.value || '').slice(0, 120) })))}`);
116
+ if (args.nth == null && candidates.length > 1) {
117
+ const preview = candidates.slice(0, 10).map((node, nth) => ({ nth, role: node.role?.value, name: String(node.name?.value || '').slice(0, 120) }));
118
+ throw Object.assign(new Error(`Role selector must match exactly one element; matched ${candidates.length}. Provide name or an explicit zero-based nth. Candidates: ${JSON.stringify(preview)}`), {
119
+ diagnostics: { reason: 'selector_ambiguous', selector_type: 'role', matched: candidates.length,
120
+ candidates: preview, omitted_candidates: Math.max(0, candidates.length - preview.length) },
121
+ });
122
+ }
102
123
  return candidates[args.nth ?? 0] || null;
103
124
  }
104
125
 
@@ -116,7 +137,7 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
116
137
  const frames = await budget.run(() => sessions.frames(tabId));
117
138
  const failure = (error, scope = {}) => {
118
139
  const candidates = frames.slice(0, 10).map(frame => ({ frame_id: frame.id, parent_frame_id: frame.parentId ?? null, url: frame.url }));
119
- error.diagnostics = { reason: 'frame_locator', ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
140
+ error.diagnostics = { reason: 'frame_locator', ...(error.diagnostics ? { cause: error.diagnostics.reason, selector_diagnostics: error.diagnostics } : {}), ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
120
141
  error.message += `; frame scope=${JSON.stringify(scope)}; current frames=${JSON.stringify(candidates)}${frames.length > candidates.length ? ` (${frames.length - candidates.length} more omitted)` : ''}`;
121
142
  return error;
122
143
  };
@@ -134,7 +155,7 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
134
155
  const send = (method, params) => budget.run(() => cdp(target, method, params));
135
156
  const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
136
157
  const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
137
- value(result);
158
+ await checkedValue(result, send, value);
138
159
  try {
139
160
  const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
140
161
  if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
@@ -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.21.0",
4
+ "version": "0.22.1",
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
+ }