github-router 0.3.52 → 0.3.68
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 +6 -5
- package/dist/browser-bridge/index.js +17 -1
- package/dist/browser-ext/background.js +358 -1
- package/dist/browser-ext/manifest.json +2 -1
- package/dist/browser-ext/snapshot-cdp.js +438 -0
- package/dist/browser-ext/snapshot.js +101 -0
- package/dist/main.js +1210 -99
- package/dist/main.js.map +1 -1
- package/package.json +18 -18
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// snapshot-cdp.js — CDP `Accessibility.getFullAXTree` based extractor.
|
|
2
|
+
//
|
|
3
|
+
// Why CDP over the in-page DOM walker:
|
|
4
|
+
// - Cross-origin iframes: chrome.scripting.executeScript can't enter
|
|
5
|
+
// them by default (Stripe checkout, OAuth widgets, embedded payment
|
|
6
|
+
// forms). CDP's Page.getFrameTree enumerates all frames including
|
|
7
|
+
// OOPIFs; Accessibility.getFullAXTree({frameId}) returns each
|
|
8
|
+
// frame's a11y tree.
|
|
9
|
+
// - Platform-computed accessible name: handles aria-labelledby chains,
|
|
10
|
+
// <label for>, fieldset/legend, button-inside-link cases the
|
|
11
|
+
// hand-rolled `nameOf` walker misses.
|
|
12
|
+
// - Real ignored state: Chrome's a11y tree marks nodes that screen
|
|
13
|
+
// readers skip (display:none, aria-hidden, role=presentation). We
|
|
14
|
+
// filter those upfront instead of guessing visibility from bbox.
|
|
15
|
+
//
|
|
16
|
+
// The extractor produces a PageSnapshot matching snapshot-types.ts.
|
|
17
|
+
// Falls through to the legacy DOM-walker extractor when CDP attach
|
|
18
|
+
// fails (enterprise DeveloperToolsAvailability=2, DevTools already
|
|
19
|
+
// open on the tab, etc.).
|
|
20
|
+
|
|
21
|
+
const ELEMENT_CAP = 500 // total elements across all frames
|
|
22
|
+
const PER_FRAME_CAP = 200 // per-frame element cap
|
|
23
|
+
const TEXT_CAP = 32 * 1024 // viewport-visible text cap
|
|
24
|
+
const VS_MIN = 100 // visualSurface min width / height
|
|
25
|
+
const CAPTURE_TIMEOUT_MS = 8000 // whole-snapshot wall-clock cap
|
|
26
|
+
|
|
27
|
+
// Roles we consider "interactive enough to bother surfacing." A
|
|
28
|
+
// liberal allowlist; the matcher cascade scores candidates further.
|
|
29
|
+
const INTERESTING_ROLES = new Set([
|
|
30
|
+
"button", "link", "checkbox", "radio", "switch", "tab", "menuitem",
|
|
31
|
+
"menuitemcheckbox", "menuitemradio", "option", "treeitem",
|
|
32
|
+
"textbox", "searchbox", "combobox", "spinbutton", "slider",
|
|
33
|
+
"listbox", "listitem", "tablist", "tabpanel",
|
|
34
|
+
"dialog", "alertdialog", "navigation", "main", "form", "search",
|
|
35
|
+
"region", "complementary", "banner", "contentinfo", "article",
|
|
36
|
+
"heading",
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const LANDMARK_ROLES = new Set([
|
|
40
|
+
"dialog", "alertdialog", "navigation", "main", "form", "search",
|
|
41
|
+
"region", "complementary", "banner", "contentinfo",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const INTERACTIVE_LEAF_ROLES = new Set([
|
|
45
|
+
"button", "link", "checkbox", "radio", "switch", "tab", "menuitem",
|
|
46
|
+
"menuitemcheckbox", "menuitemradio", "option", "treeitem",
|
|
47
|
+
"textbox", "searchbox", "combobox", "spinbutton", "slider",
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract a page snapshot via CDP. Throws on attach failure or
|
|
52
|
+
* whole-capture timeout — caller falls back to the legacy extractor.
|
|
53
|
+
*
|
|
54
|
+
* `attachDebugger` and `sendCommand` are passed in so this module
|
|
55
|
+
* stays decoupled from the SW-global debugger state in background.js;
|
|
56
|
+
* makes the extractor unit-testable with a mock CDP later.
|
|
57
|
+
*/
|
|
58
|
+
export async function extractSnapshotCDP(tabId, opts, deps) {
|
|
59
|
+
const mode = opts?.mode === "full" ? "full" : "summary"
|
|
60
|
+
const attachDebugger = deps?.attachDebugger
|
|
61
|
+
const sendCommand = deps?.sendCommand
|
|
62
|
+
if (typeof attachDebugger !== "function" || typeof sendCommand !== "function") {
|
|
63
|
+
throw new Error("snapshot-cdp: missing attachDebugger / sendCommand deps")
|
|
64
|
+
}
|
|
65
|
+
await attachDebugger(tabId, { accessibility: true })
|
|
66
|
+
|
|
67
|
+
// Race against a whole-capture wall-clock budget so a slow
|
|
68
|
+
// upstream can't hang the dispatcher.
|
|
69
|
+
let timedOut = false
|
|
70
|
+
const timer = setTimeout(() => { timedOut = true }, CAPTURE_TIMEOUT_MS)
|
|
71
|
+
try {
|
|
72
|
+
const tab = await chrome.tabs.get(tabId)
|
|
73
|
+
const viewport = await captureViewport(tabId, sendCommand)
|
|
74
|
+
// DOM.getDocument must be called before DOM.pushNodesByBackendIdsToFrontend
|
|
75
|
+
// can resolve anything — DOM.enable alone does NOT materialize the
|
|
76
|
+
// protocol-side node tree, which is the gotcha that bit the first
|
|
77
|
+
// implementation (1597 AX nodes, 0 resolved). pierce:true crosses
|
|
78
|
+
// into shadow roots and iframe content documents in one round-trip.
|
|
79
|
+
try {
|
|
80
|
+
await sendCommand(tabId, "DOM.getDocument", { depth: -1, pierce: true })
|
|
81
|
+
} catch {
|
|
82
|
+
// Best-effort — getDocument can fail on detached / about:blank
|
|
83
|
+
// frames; pushNodes calls below will throw cleanly per-frame.
|
|
84
|
+
}
|
|
85
|
+
const frameTree = await sendCommand(tabId, "Page.getFrameTree", {})
|
|
86
|
+
const frames = flattenFrameTree(frameTree.frameTree)
|
|
87
|
+
let truncatedElements = false
|
|
88
|
+
let framesSkipped = 0
|
|
89
|
+
const elements = []
|
|
90
|
+
const refCounter = { next: 1 }
|
|
91
|
+
const usedRefs = new Set()
|
|
92
|
+
const diag = { frames: frames.length, axNodes: 0, interesting: 0, resolved: 0, withRef: 0 }
|
|
93
|
+
for (const frame of frames) {
|
|
94
|
+
if (timedOut) break
|
|
95
|
+
if (elements.length >= ELEMENT_CAP) {
|
|
96
|
+
truncatedElements = true
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const frameElements = await extractFrameElements({
|
|
101
|
+
tabId,
|
|
102
|
+
frame,
|
|
103
|
+
parentFrameOffset: frame.offset,
|
|
104
|
+
isTopFrame: frame === frames[0],
|
|
105
|
+
mode,
|
|
106
|
+
viewport,
|
|
107
|
+
remainingCap: ELEMENT_CAP - elements.length,
|
|
108
|
+
refCounter,
|
|
109
|
+
usedRefs,
|
|
110
|
+
sendCommand,
|
|
111
|
+
diag,
|
|
112
|
+
})
|
|
113
|
+
elements.push(...frameElements)
|
|
114
|
+
} catch {
|
|
115
|
+
framesSkipped++
|
|
116
|
+
// Per-frame failure (cross-origin OOPIF refusing the call,
|
|
117
|
+
// detached frame, sandbox restriction) is logged but not
|
|
118
|
+
// fatal. The top frame's failure WOULD be fatal, but its
|
|
119
|
+
// attach already succeeded so an enable failure is rare.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const text = await extractVisibleText(tabId, sendCommand).catch(() => "")
|
|
123
|
+
const truncatedText = text.length >= TEXT_CAP
|
|
124
|
+
const visualSurfaces = await extractVisualSurfaces(tabId, sendCommand).catch(() => [])
|
|
125
|
+
const out = {
|
|
126
|
+
mode,
|
|
127
|
+
tabId,
|
|
128
|
+
url: tab.url,
|
|
129
|
+
title: tab.title,
|
|
130
|
+
capturedAt: Date.now(),
|
|
131
|
+
viewport,
|
|
132
|
+
text: text.slice(0, TEXT_CAP),
|
|
133
|
+
elements,
|
|
134
|
+
truncated: {
|
|
135
|
+
elements: truncatedElements,
|
|
136
|
+
text: truncatedText,
|
|
137
|
+
framesSkipped,
|
|
138
|
+
diag,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
if (visualSurfaces.length > 0) out.visualSurfaces = visualSurfaces
|
|
142
|
+
return out
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function flattenFrameTree(node, out, parentOffset) {
|
|
149
|
+
if (!out) out = []
|
|
150
|
+
if (!node) return out
|
|
151
|
+
const frame = node.frame || node
|
|
152
|
+
const offset = parentOffset ?? { x: 0, y: 0 }
|
|
153
|
+
out.push({
|
|
154
|
+
frameId: frame.id,
|
|
155
|
+
url: frame.url,
|
|
156
|
+
parentId: frame.parentId,
|
|
157
|
+
offset,
|
|
158
|
+
})
|
|
159
|
+
if (Array.isArray(node.childFrames)) {
|
|
160
|
+
for (const child of node.childFrames) {
|
|
161
|
+
// Child offset relative to top frame: we'd need each iframe
|
|
162
|
+
// element's bbox to transform. Skipping for now — bbox of
|
|
163
|
+
// elements inside child frames is iframe-local CSS pixels.
|
|
164
|
+
// The matcher cascade still resolves them by ref via
|
|
165
|
+
// data-gh-router-ref; the bbox is best-effort.
|
|
166
|
+
flattenFrameTree(child, out, offset)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function captureViewport(tabId, sendCommand) {
|
|
173
|
+
try {
|
|
174
|
+
const layoutMetrics = await sendCommand(tabId, "Page.getLayoutMetrics", {})
|
|
175
|
+
const v = layoutMetrics.cssVisualViewport ?? layoutMetrics.visualViewport ?? {}
|
|
176
|
+
return {
|
|
177
|
+
width: Math.round(v.clientWidth ?? v.width ?? 0),
|
|
178
|
+
height: Math.round(v.clientHeight ?? v.height ?? 0),
|
|
179
|
+
devicePixelRatio: v.scale ?? 1,
|
|
180
|
+
scrollX: Math.round(v.pageX ?? 0),
|
|
181
|
+
scrollY: Math.round(v.pageY ?? 0),
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return { width: 0, height: 0, devicePixelRatio: 1, scrollX: 0, scrollY: 0 }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function extractFrameElements({
|
|
189
|
+
tabId, frame, parentFrameOffset, isTopFrame, mode, viewport,
|
|
190
|
+
remainingCap, refCounter, usedRefs, sendCommand, diag,
|
|
191
|
+
}) {
|
|
192
|
+
const cap = Math.min(PER_FRAME_CAP, remainingCap)
|
|
193
|
+
const params = isTopFrame ? {} : { frameId: frame.frameId }
|
|
194
|
+
const result = await sendCommand(tabId, "Accessibility.getFullAXTree", params)
|
|
195
|
+
const nodes = Array.isArray(result.nodes) ? result.nodes : []
|
|
196
|
+
if (diag) diag.axNodes += nodes.length
|
|
197
|
+
// Pre-pass: collect landmark nodes by AXNode id so leaf nodes can
|
|
198
|
+
// attribute their ancestry without walking the whole tree.
|
|
199
|
+
const landmarkByAxId = new Map()
|
|
200
|
+
for (const n of nodes) {
|
|
201
|
+
const role = n.role?.value
|
|
202
|
+
if (role && LANDMARK_ROLES.has(role) && !n.ignored) {
|
|
203
|
+
landmarkByAxId.set(n.nodeId, n)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Parent map for ancestry walks.
|
|
207
|
+
const parentById = new Map()
|
|
208
|
+
for (const n of nodes) {
|
|
209
|
+
if (Array.isArray(n.childIds)) {
|
|
210
|
+
for (const cid of n.childIds) parentById.set(cid, n.nodeId)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function landmarksOf(axId) {
|
|
214
|
+
const refs = []
|
|
215
|
+
let cur = parentById.get(axId)
|
|
216
|
+
while (cur !== undefined && refs.length < 4) {
|
|
217
|
+
if (landmarkByAxId.has(cur)) {
|
|
218
|
+
const lm = landmarkByAxId.get(cur)
|
|
219
|
+
const r = lm._ghRouterRef
|
|
220
|
+
if (r) refs.push(r)
|
|
221
|
+
}
|
|
222
|
+
cur = parentById.get(cur)
|
|
223
|
+
}
|
|
224
|
+
return refs
|
|
225
|
+
}
|
|
226
|
+
// Filter to interesting + has backendDOMNodeId; cap per-frame.
|
|
227
|
+
const interesting = nodes.filter((n) => {
|
|
228
|
+
if (n.ignored) return false
|
|
229
|
+
const role = n.role?.value
|
|
230
|
+
if (!role || !INTERESTING_ROLES.has(role)) return false
|
|
231
|
+
if (typeof n.backendDOMNodeId !== "number") return false
|
|
232
|
+
return true
|
|
233
|
+
}).slice(0, cap)
|
|
234
|
+
if (diag) diag.interesting += interesting.length
|
|
235
|
+
// Resolve backendDOMNodeIds to frontend nodeIds in one batch.
|
|
236
|
+
const backendIds = interesting.map((n) => n.backendDOMNodeId)
|
|
237
|
+
let nodeIds = []
|
|
238
|
+
if (backendIds.length > 0) {
|
|
239
|
+
try {
|
|
240
|
+
const resolved = await sendCommand(tabId, "DOM.pushNodesByBackendIdsToFrontend", {
|
|
241
|
+
backendNodeIds: backendIds,
|
|
242
|
+
})
|
|
243
|
+
nodeIds = Array.isArray(resolved.nodeIds) ? resolved.nodeIds : []
|
|
244
|
+
if (diag) diag.resolved += nodeIds.filter((n) => n).length
|
|
245
|
+
} catch {
|
|
246
|
+
// DOM.pushNodes can fail per-frame on cross-origin. Best-effort.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const out = []
|
|
250
|
+
for (let i = 0; i < interesting.length; i++) {
|
|
251
|
+
const ax = interesting[i]
|
|
252
|
+
const nodeId = nodeIds[i]
|
|
253
|
+
if (!nodeId) continue
|
|
254
|
+
// Read existing ref or mint new one.
|
|
255
|
+
let ref
|
|
256
|
+
try {
|
|
257
|
+
const attrs = await sendCommand(tabId, "DOM.getAttributes", { nodeId })
|
|
258
|
+
ref = attrFromList(attrs.attributes, "data-gh-router-ref")
|
|
259
|
+
} catch {
|
|
260
|
+
ref = undefined
|
|
261
|
+
}
|
|
262
|
+
if (!ref || !/^e\d+$/.test(ref)) {
|
|
263
|
+
while (usedRefs.has(`e${refCounter.next}`)) refCounter.next++
|
|
264
|
+
ref = `e${refCounter.next}`
|
|
265
|
+
refCounter.next++
|
|
266
|
+
usedRefs.add(ref)
|
|
267
|
+
try {
|
|
268
|
+
await sendCommand(tabId, "DOM.setAttributeValue", {
|
|
269
|
+
nodeId, name: "data-gh-router-ref", value: ref,
|
|
270
|
+
})
|
|
271
|
+
} catch {
|
|
272
|
+
// Read-only doc or shadow boundary; skip this element.
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
usedRefs.add(ref)
|
|
277
|
+
}
|
|
278
|
+
ax._ghRouterRef = ref
|
|
279
|
+
// Get bbox via DOM.getBoxModel. Best-effort; off-screen / detached
|
|
280
|
+
// nodes throw.
|
|
281
|
+
let bbox = [0, 0, 0, 0]
|
|
282
|
+
try {
|
|
283
|
+
const box = await sendCommand(tabId, "DOM.getBoxModel", { nodeId })
|
|
284
|
+
const m = box.model
|
|
285
|
+
if (m && Array.isArray(m.border) && m.border.length >= 4) {
|
|
286
|
+
// border is [x1,y1, x2,y1, x2,y2, x1,y2] — 8 numbers
|
|
287
|
+
const x = m.border[0]
|
|
288
|
+
const y = m.border[1]
|
|
289
|
+
const w = m.width
|
|
290
|
+
const h = m.height
|
|
291
|
+
bbox = [
|
|
292
|
+
Math.round(x + parentFrameOffset.x),
|
|
293
|
+
Math.round(y + parentFrameOffset.y),
|
|
294
|
+
Math.round(w),
|
|
295
|
+
Math.round(h),
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// ignore
|
|
300
|
+
}
|
|
301
|
+
// Skip elements with zero-size bbox in summary mode — they're
|
|
302
|
+
// hidden by display:none-ish parent or detached.
|
|
303
|
+
if (mode === "summary" && bbox[2] === 0 && bbox[3] === 0) continue
|
|
304
|
+
// Skip elements outside the viewport in summary mode (top-frame
|
|
305
|
+
// coord space). Skip when bbox is unknown (0,0,0,0) too.
|
|
306
|
+
if (mode === "summary" && !inViewport(bbox, viewport) && bbox.some((n) => n !== 0)) continue
|
|
307
|
+
const role = ax.role?.value || "generic"
|
|
308
|
+
const name = (ax.name?.value || "").trim().slice(0, 200)
|
|
309
|
+
const description = (ax.description?.value || "").trim().slice(0, 200)
|
|
310
|
+
const valueStr = (ax.value?.value !== undefined && ax.value?.value !== null)
|
|
311
|
+
? String(ax.value.value).trim().slice(0, 200)
|
|
312
|
+
: ""
|
|
313
|
+
// Drop unnamed leaf interactive elements in summary mode (a
|
|
314
|
+
// <button> with no accessible name is noise).
|
|
315
|
+
if (mode === "summary" && !name && INTERACTIVE_LEAF_ROLES.has(role)) continue
|
|
316
|
+
const entry = {
|
|
317
|
+
ref,
|
|
318
|
+
role,
|
|
319
|
+
bbox,
|
|
320
|
+
frameId: frame.frameId,
|
|
321
|
+
}
|
|
322
|
+
if (!isTopFrame) entry.isInIframe = true
|
|
323
|
+
if (name) entry.name = name
|
|
324
|
+
if (description) entry.description = description
|
|
325
|
+
if (valueStr) entry.value = valueStr
|
|
326
|
+
// State flags from AXNode.properties — Chrome surfaces these in
|
|
327
|
+
// a strongly-typed bag distinct from the generic value field.
|
|
328
|
+
const props = ax.properties
|
|
329
|
+
if (Array.isArray(props)) {
|
|
330
|
+
for (const p of props) {
|
|
331
|
+
const k = p.name
|
|
332
|
+
const v = p.value?.value
|
|
333
|
+
if (k === "disabled" && v) entry.disabled = true
|
|
334
|
+
else if (k === "focused" && v) entry.focused = true
|
|
335
|
+
else if (k === "checked" && v !== undefined && v !== false) {
|
|
336
|
+
entry.checked = v === "mixed" ? "mixed" : Boolean(v)
|
|
337
|
+
}
|
|
338
|
+
else if (k === "expanded" && v !== undefined) entry.expanded = Boolean(v)
|
|
339
|
+
else if (k === "selected" && v) entry.selected = true
|
|
340
|
+
else if (k === "pressed" && v !== undefined && v !== false) entry.pressed = Boolean(v)
|
|
341
|
+
else if (k === "hidden" && v) entry.hidden = true
|
|
342
|
+
else if (k === "required" && v) entry.required = true
|
|
343
|
+
else if (k === "readonly" && v) entry.readonly = true
|
|
344
|
+
else if (k === "invalid" && v) entry.invalid = v === true ? true : String(v)
|
|
345
|
+
else if (k === "editable" && v) entry.editable = true
|
|
346
|
+
else if (k === "level" && typeof v === "number") entry.level = v
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Landmark ancestry (ref-chain up to 4 deep).
|
|
350
|
+
const lm = landmarksOf(ax.nodeId)
|
|
351
|
+
if (lm.length > 0) entry.landmarks = lm
|
|
352
|
+
out.push(entry)
|
|
353
|
+
if (diag) diag.withRef++
|
|
354
|
+
}
|
|
355
|
+
return out
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function inViewport(bbox, viewport) {
|
|
359
|
+
const [x, y, w, h] = bbox
|
|
360
|
+
return x < viewport.width && y < viewport.height && x + w > 0 && y + h > 0
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function attrFromList(attrList, name) {
|
|
364
|
+
if (!Array.isArray(attrList)) return undefined
|
|
365
|
+
for (let i = 0; i < attrList.length; i += 2) {
|
|
366
|
+
if (attrList[i] === name) return attrList[i + 1]
|
|
367
|
+
}
|
|
368
|
+
return undefined
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function extractVisibleText(tabId, sendCommand) {
|
|
372
|
+
// Single Runtime.evaluate call into the page's main world to grab
|
|
373
|
+
// viewport-visible text. Same logic as the legacy extractor.
|
|
374
|
+
const expr = `
|
|
375
|
+
(function() {
|
|
376
|
+
const out = [];
|
|
377
|
+
let total = 0;
|
|
378
|
+
const CAP = ${TEXT_CAP};
|
|
379
|
+
const root = document.body || document.documentElement;
|
|
380
|
+
if (!root) return "";
|
|
381
|
+
const tw = document.createTreeWalker(root, 4);
|
|
382
|
+
const vp = { w: window.innerWidth, h: window.innerHeight };
|
|
383
|
+
function inV(r) { return r.bottom > 0 && r.right > 0 && r.top < vp.h && r.left < vp.w; }
|
|
384
|
+
let n;
|
|
385
|
+
while ((n = tw.nextNode())) {
|
|
386
|
+
const p = n.parentElement;
|
|
387
|
+
if (!p) continue;
|
|
388
|
+
const t = p.tagName ? p.tagName.toLowerCase() : "";
|
|
389
|
+
if (t === "script" || t === "style" || t === "noscript") continue;
|
|
390
|
+
const r = p.getBoundingClientRect();
|
|
391
|
+
if (!inV(r)) continue;
|
|
392
|
+
const s = (n.textContent || "").replace(/\\s+/g, " ").trim();
|
|
393
|
+
if (!s) continue;
|
|
394
|
+
if (total + s.length + 1 > CAP) { out.push(s.slice(0, Math.max(0, CAP - total))); break; }
|
|
395
|
+
out.push(s);
|
|
396
|
+
total += s.length + 1;
|
|
397
|
+
}
|
|
398
|
+
return out.join("\\n");
|
|
399
|
+
})()
|
|
400
|
+
`
|
|
401
|
+
const res = await sendCommand(tabId, "Runtime.evaluate", {
|
|
402
|
+
expression: expr,
|
|
403
|
+
returnByValue: true,
|
|
404
|
+
})
|
|
405
|
+
return res?.result?.value ?? ""
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function extractVisualSurfaces(tabId, sendCommand) {
|
|
409
|
+
// Find canvas / svg of non-trivial size in viewport. Runtime.evaluate
|
|
410
|
+
// is the cheapest path; AX tree doesn't surface canvas/svg directly.
|
|
411
|
+
const expr = `
|
|
412
|
+
(function() {
|
|
413
|
+
const out = [];
|
|
414
|
+
const vp = { w: window.innerWidth, h: window.innerHeight };
|
|
415
|
+
function inV(r) { return r.bottom > 0 && r.right > 0 && r.top < vp.h && r.left < vp.w; }
|
|
416
|
+
const els = document.querySelectorAll("canvas, svg");
|
|
417
|
+
let counter = 1;
|
|
418
|
+
for (const el of els) {
|
|
419
|
+
const r = el.getBoundingClientRect();
|
|
420
|
+
if (r.width < ${VS_MIN} || r.height < ${VS_MIN}) continue;
|
|
421
|
+
if (!inV(r)) continue;
|
|
422
|
+
let ref = el.getAttribute("data-gh-router-ref");
|
|
423
|
+
if (!ref) { ref = "v" + counter++; el.setAttribute("data-gh-router-ref", ref); }
|
|
424
|
+
out.push({
|
|
425
|
+
ref,
|
|
426
|
+
kind: el.tagName.toLowerCase(),
|
|
427
|
+
bbox: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
})()
|
|
432
|
+
`
|
|
433
|
+
const res = await sendCommand(tabId, "Runtime.evaluate", {
|
|
434
|
+
expression: expr,
|
|
435
|
+
returnByValue: true,
|
|
436
|
+
})
|
|
437
|
+
return Array.isArray(res?.result?.value) ? res.result.value : []
|
|
438
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// snapshot.js — extension-side snapshot pipeline for the deterministic-
|
|
2
|
+
// first browser MCP refactor.
|
|
3
|
+
//
|
|
4
|
+
// Phase 1b ships the CACHE + INVALIDATION infrastructure with the legacy
|
|
5
|
+
// `document.querySelectorAll`-based extraction still doing the actual
|
|
6
|
+
// work (delegated back to background.js). Phase 1b-CDP (next commit)
|
|
7
|
+
// swaps the extractor for a CDP `Accessibility.getFullAXTree`-based
|
|
8
|
+
// implementation that auto-stitches Shadow DOM + cross-origin iframes
|
|
9
|
+
// and populates the rich state fields the matcher cascade needs.
|
|
10
|
+
//
|
|
11
|
+
// Public surface:
|
|
12
|
+
// captureSnapshot(tabId, opts) - extract a fresh snapshot (writes cache)
|
|
13
|
+
// getCachedSnapshot(tabId) - read-through cache, undefined on miss
|
|
14
|
+
// invalidateSnapshot(tabId, reason) - clear cache for a tab
|
|
15
|
+
// anyCachedSnapshots() - cheap predicate (cache-hit early-exit)
|
|
16
|
+
//
|
|
17
|
+
// Cache invalidation is action-driven, not time-driven. Mutating tools
|
|
18
|
+
// (click / fill / type / keyboard / scroll / mouse / drag) flag
|
|
19
|
+
// `mutatesPage: true` in the dispatcher; that flag triggers an
|
|
20
|
+
// invalidation in the success path. Navigation + tab-close trigger
|
|
21
|
+
// invalidations via the listeners registered alongside the existing
|
|
22
|
+
// debugger.onDetach / tabs.onRemoved handlers in background.js.
|
|
23
|
+
|
|
24
|
+
const cache = new Map() // tabId -> PageSnapshot
|
|
25
|
+
// Debug logging is off by default. Flip via globalThis.__GH_ROUTER_DEBUG_SNAPSHOT
|
|
26
|
+
// = true from the extension's service-worker console for ad-hoc tracing.
|
|
27
|
+
// Avoids depending on process.env (not available in extension context)
|
|
28
|
+
// or chrome.storage (sync I/O on the hot path).
|
|
29
|
+
function isDebug() {
|
|
30
|
+
return Boolean(globalThis.__GH_ROUTER_DEBUG_SNAPSHOT)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read-through cache lookup. Returns undefined on miss.
|
|
35
|
+
*/
|
|
36
|
+
export function getCachedSnapshot(tabId) {
|
|
37
|
+
return cache.get(tabId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True if any tab currently has a cached snapshot. Used by the matcher
|
|
42
|
+
* cascade's fast-path bypass — when nothing is cached, no point
|
|
43
|
+
* consulting per-tab state.
|
|
44
|
+
*/
|
|
45
|
+
export function anyCachedSnapshots() {
|
|
46
|
+
return cache.size > 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drop the cache entry for a tab. `reason` is logged when the debug
|
|
51
|
+
* channel is on; helps diagnose "why is my cache cold?" without a
|
|
52
|
+
* breakpoint. Idempotent — calling on a missing tabId is a no-op.
|
|
53
|
+
*/
|
|
54
|
+
export function invalidateSnapshot(tabId, reason) {
|
|
55
|
+
if (!cache.has(tabId)) return
|
|
56
|
+
cache.delete(tabId)
|
|
57
|
+
if (isDebug()) {
|
|
58
|
+
console.debug(`[browser-mcp/snapshot] invalidated tab ${tabId} (${reason})`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop every cached snapshot. Used on bridge / extension restart paths.
|
|
64
|
+
*/
|
|
65
|
+
export function clearAllSnapshots() {
|
|
66
|
+
cache.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Capture a fresh snapshot. The extractor is passed in by the caller so
|
|
71
|
+
* this module stays decoupled from the specific extraction strategy —
|
|
72
|
+
* legacy DOM walker today, CDP a11y tree once Phase 1b-CDP lands. The
|
|
73
|
+
* extractor function receives `(tabId, opts)` and returns a
|
|
74
|
+
* `PageSnapshot`-shaped object.
|
|
75
|
+
*
|
|
76
|
+
* The captured snapshot is written to the cache before being returned,
|
|
77
|
+
* so a follow-up `getCachedSnapshot(tabId)` in the same turn is a hit.
|
|
78
|
+
* Caller passes `refresh: true` in opts to force a re-capture (the
|
|
79
|
+
* snapshot is still written through the cache).
|
|
80
|
+
*/
|
|
81
|
+
export async function captureSnapshot(tabId, opts, extractor) {
|
|
82
|
+
if (typeof tabId !== "number") {
|
|
83
|
+
throw new Error("snapshot.captureSnapshot: tabId must be a number")
|
|
84
|
+
}
|
|
85
|
+
if (typeof extractor !== "function") {
|
|
86
|
+
throw new Error("snapshot.captureSnapshot: extractor must be a function")
|
|
87
|
+
}
|
|
88
|
+
const refresh = opts?.refresh === true
|
|
89
|
+
if (!refresh) {
|
|
90
|
+
const cached = cache.get(tabId)
|
|
91
|
+
if (cached) return cached
|
|
92
|
+
}
|
|
93
|
+
const snapshot = await extractor(tabId, opts)
|
|
94
|
+
// Normalize: ensure tabId + capturedAt are populated even when the
|
|
95
|
+
// extractor doesn't supply them, so downstream consumers can rely on
|
|
96
|
+
// these without optional-chaining.
|
|
97
|
+
snapshot.tabId = tabId
|
|
98
|
+
snapshot.capturedAt = Date.now()
|
|
99
|
+
cache.set(tabId, snapshot)
|
|
100
|
+
return snapshot
|
|
101
|
+
}
|