browserwire 0.1.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,495 @@
1
+ /**
2
+ * discovery.js — Stage 1 (DOM Scan) + Stage 2 (A11y Extraction)
3
+ *
4
+ * Collects raw DOM structure and accessibility data from the live page.
5
+ * All processing happens on the backend — this is a dumb data collector.
6
+ */
7
+
8
+ const SCAN_ELEMENT_CAP = 5000;
9
+
10
+ const SKIP_TAGS = new Set([
11
+ "script", "style", "noscript", "template", "svg", "path"
12
+ ]);
13
+
14
+ const IMPLICIT_ROLES = {
15
+ button: "button",
16
+ a: "link",
17
+ textarea: "textbox",
18
+ nav: "navigation",
19
+ main: "main",
20
+ form: "form",
21
+ table: "table",
22
+ ul: "list",
23
+ ol: "list",
24
+ li: "listitem",
25
+ dialog: "dialog",
26
+ article: "article",
27
+ section: "region",
28
+ aside: "complementary",
29
+ header: "banner",
30
+ footer: "contentinfo",
31
+ fieldset: "group",
32
+ details: "group",
33
+ summary: "button",
34
+ output: "status",
35
+ progress: "progressbar",
36
+ meter: "meter"
37
+ };
38
+
39
+ const INPUT_ROLE_MAP = {
40
+ text: "textbox",
41
+ search: "searchbox",
42
+ email: "textbox",
43
+ tel: "textbox",
44
+ url: "textbox",
45
+ password: "textbox",
46
+ number: "spinbutton",
47
+ range: "slider",
48
+ checkbox: "checkbox",
49
+ radio: "radio",
50
+ button: "button",
51
+ submit: "button",
52
+ reset: "button",
53
+ image: "button"
54
+ };
55
+
56
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
57
+
58
+ /**
59
+ * Stage 1 — DOM Scan
60
+ * Walk the live DOM and collect structural data about every visible element.
61
+ */
62
+ const scanDOM = () => {
63
+ const elements = [];
64
+ let nextScanId = 0;
65
+ const nodeToScanId = new Map();
66
+
67
+ const isVisible = (el) => {
68
+ if (!(el instanceof HTMLElement)) return false;
69
+ const style = window.getComputedStyle(el);
70
+ if (style.display === "none" || style.visibility === "hidden") return false;
71
+ const rect = el.getBoundingClientRect();
72
+ if (rect.width === 0 && rect.height === 0) return false;
73
+ return true;
74
+ };
75
+
76
+ const getDirectText = (el) => {
77
+ let text = "";
78
+ for (const child of el.childNodes) {
79
+ if (child.nodeType === Node.TEXT_NODE) {
80
+ text += child.textContent;
81
+ }
82
+ }
83
+ return text.trim().slice(0, 200);
84
+ };
85
+
86
+ const getAttributes = (el) => {
87
+ const attrs = {};
88
+ for (const attr of el.attributes) {
89
+ attrs[attr.name] = attr.value;
90
+ }
91
+ return attrs;
92
+ };
93
+
94
+ const walk = (el, parentScanId) => {
95
+ if (nextScanId >= SCAN_ELEMENT_CAP) return;
96
+
97
+ const tag = el.tagName.toLowerCase();
98
+ if (SKIP_TAGS.has(tag)) return;
99
+
100
+ if (!isVisible(el)) return;
101
+
102
+ const scanId = nextScanId++;
103
+ nodeToScanId.set(el, scanId);
104
+
105
+ const rect = el.getBoundingClientRect();
106
+ const element = {
107
+ scanId,
108
+ parentScanId,
109
+ tagName: tag,
110
+ attributes: getAttributes(el),
111
+ textContent: getDirectText(el),
112
+ boundingRect: {
113
+ x: Math.round(rect.x),
114
+ y: Math.round(rect.y),
115
+ width: Math.round(rect.width),
116
+ height: Math.round(rect.height)
117
+ },
118
+ isVisible: true,
119
+ childScanIds: []
120
+ };
121
+
122
+ elements.push(element);
123
+
124
+ // Walk children (including open shadow roots)
125
+ const childRoot = el.shadowRoot && el.shadowRoot.mode === "open"
126
+ ? el.shadowRoot
127
+ : el;
128
+
129
+ for (const child of childRoot.children) {
130
+ if (child instanceof HTMLElement) {
131
+ walk(child, scanId);
132
+ const childScanId = nodeToScanId.get(child);
133
+ if (childScanId !== undefined) {
134
+ element.childScanIds.push(childScanId);
135
+ }
136
+ }
137
+ }
138
+ };
139
+
140
+ if (document.body) {
141
+ walk(document.body, null);
142
+ }
143
+
144
+ return elements;
145
+ };
146
+
147
+ /**
148
+ * Stage 2 — Accessibility Extraction
149
+ * Overlay accessibility information onto scanned elements.
150
+ */
151
+ const extractA11y = (elements) => {
152
+ const a11yData = [];
153
+
154
+ // We need to find the live DOM elements again by matching scanId.
155
+ // Since scanDOM walked in order, we can re-walk to build the map,
156
+ // but it's simpler to store element refs during scan.
157
+ // Instead, let's derive a11y purely from the scanned data (attributes + tag).
158
+
159
+ for (const el of elements) {
160
+ const tag = el.tagName;
161
+ const attrs = el.attributes;
162
+
163
+ // Compute role
164
+ let role = attrs.role || null;
165
+ if (!role) {
166
+ if (tag === "input") {
167
+ const inputType = (attrs.type || "text").toLowerCase();
168
+ role = INPUT_ROLE_MAP[inputType] || null;
169
+ } else if (tag === "select") {
170
+ const multiple = "multiple" in attrs;
171
+ role = multiple ? "listbox" : "combobox";
172
+ } else if (tag === "img" && ("alt" in attrs)) {
173
+ role = "img";
174
+ } else if (HEADING_TAGS.has(tag)) {
175
+ role = "heading";
176
+ } else {
177
+ role = IMPLICIT_ROLES[tag] || null;
178
+ }
179
+ }
180
+
181
+ // Compute accessible name
182
+ let name = attrs["aria-label"] || null;
183
+ if (!name && attrs.title) {
184
+ name = attrs.title;
185
+ }
186
+ if (!name && attrs.placeholder) {
187
+ name = attrs.placeholder;
188
+ }
189
+ if (!name && attrs.alt) {
190
+ name = attrs.alt;
191
+ }
192
+ if (!name && el.textContent) {
193
+ name = el.textContent.slice(0, 100);
194
+ }
195
+
196
+ // Description
197
+ const description = attrs["aria-description"] || null;
198
+
199
+ // States
200
+ const isDisabled = "disabled" in attrs ||
201
+ attrs["aria-disabled"] === "true";
202
+
203
+ const isRequired = "required" in attrs ||
204
+ attrs["aria-required"] === "true";
205
+
206
+ const expandedState = attrs["aria-expanded"] || null;
207
+
208
+ let checkedState = attrs["aria-checked"] || null;
209
+ if (!checkedState && tag === "input" && (attrs.type === "checkbox" || attrs.type === "radio")) {
210
+ checkedState = "checked" in attrs ? "true" : "false";
211
+ }
212
+
213
+ const selectedState = attrs["aria-selected"] || null;
214
+
215
+ a11yData.push({
216
+ scanId: el.scanId,
217
+ role,
218
+ name,
219
+ description,
220
+ isDisabled,
221
+ isRequired,
222
+ expandedState,
223
+ checkedState,
224
+ selectedState
225
+ });
226
+ }
227
+
228
+ return a11yData;
229
+ };
230
+
231
+ /**
232
+ * Collect visible page text (first ~2000 chars) for LLM context.
233
+ */
234
+ const collectPageText = () => {
235
+ const walker = document.createTreeWalker(
236
+ document.body,
237
+ NodeFilter.SHOW_TEXT,
238
+ {
239
+ acceptNode(node) {
240
+ const parent = node.parentElement;
241
+ if (!parent) return NodeFilter.FILTER_REJECT;
242
+ const tag = parent.tagName.toLowerCase();
243
+ if (tag === "script" || tag === "style" || tag === "noscript") {
244
+ return NodeFilter.FILTER_REJECT;
245
+ }
246
+ const text = (node.textContent || "").trim();
247
+ if (text.length === 0) return NodeFilter.FILTER_REJECT;
248
+ return NodeFilter.FILTER_ACCEPT;
249
+ }
250
+ }
251
+ );
252
+
253
+ let result = "";
254
+ let node;
255
+ while ((node = walker.nextNode()) && result.length < 2200) {
256
+ const text = (node.textContent || "").trim();
257
+ if (text.length > 0) {
258
+ result += text + " ";
259
+ }
260
+ }
261
+
262
+ return result.trim().slice(0, 2000);
263
+ };
264
+
265
+ /**
266
+ * Run a full discovery scan and return a RawPageSnapshot.
267
+ */
268
+ const runDiscoveryScan = () => {
269
+ const elements = scanDOM();
270
+ const a11y = extractA11y(elements);
271
+ const pageText = collectPageText();
272
+
273
+ return {
274
+ url: window.location.href,
275
+ title: document.title,
276
+ capturedAt: new Date().toISOString(),
277
+ elements,
278
+ a11y,
279
+ pageText
280
+ };
281
+ };
282
+
283
+ // ─── Element State Capture ───────────────────────────────────────────
284
+
285
+ /**
286
+ * Capture live DOM state (runtime properties, not HTML attributes).
287
+ * Returns null when all defaults — keeps token count low.
288
+ */
289
+ const captureElementState = (el) => {
290
+ const tag = el.tagName.toLowerCase();
291
+ const state = {};
292
+
293
+ // Form field values (live .value, not attribute)
294
+ if ((tag === 'input' || tag === 'textarea' || tag === 'select') && 'value' in el) {
295
+ state.value = el.value || null;
296
+ }
297
+ if (tag === 'input' && (el.type === 'checkbox' || el.type === 'radio')) {
298
+ state.checked = el.checked;
299
+ }
300
+ if (tag === 'select' && el.selectedIndex >= 0 && el.options[el.selectedIndex]) {
301
+ state.selectedOption = el.options[el.selectedIndex].text || null;
302
+ }
303
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') {
304
+ state.disabled = true;
305
+ }
306
+ const expanded = el.getAttribute('aria-expanded');
307
+ if (expanded !== null) state.expanded = expanded === 'true';
308
+ const selected = el.getAttribute('aria-selected');
309
+ if (selected !== null) state.selected = selected === 'true';
310
+
311
+ return Object.keys(state).length > 0 ? state : null;
312
+ };
313
+
314
+ /**
315
+ * Capture page-level state context.
316
+ */
317
+ const capturePageState = () => ({
318
+ pathname: window.location.pathname,
319
+ hash: window.location.hash,
320
+ search: window.location.search,
321
+ dialogOpen: document.querySelector('dialog[open]') !== null,
322
+ scrollY: Math.round(window.scrollY)
323
+ });
324
+
325
+ // ─── Skeleton Scan (Vision-First Pipeline) ──────────────────────────
326
+
327
+ const SKELETON_INTERACTABLE_TAGS = new Set([
328
+ "button", "a", "input", "select", "textarea", "summary"
329
+ ]);
330
+
331
+ const SKELETON_LANDMARK_TAGS = new Set([
332
+ "nav", "main", "header", "footer", "form", "dialog", "aside", "section", "article"
333
+ ]);
334
+
335
+ const SKELETON_ATTRS = ["data-testid", "id", "aria-label", "href", "type", "name", "placeholder", "class"];
336
+
337
+ /**
338
+ * Run a focused skeleton scan for the vision-first discovery pipeline.
339
+ * Collects only landmark containers and interactable elements (~50-200 nodes).
340
+ *
341
+ * Each entry: { scanId, tagName, role, name, text, rect, attributes,
342
+ * parentScanId, childScanIds, interactable }
343
+ *
344
+ * parentScanId/childScanIds form a logical tree over the included nodes
345
+ * (non-included ancestors are skipped). This is sufficient for attribute-based
346
+ * locators; dom_path/xpath locators will be approximate.
347
+ */
348
+ const runSkeletonScan = () => {
349
+ const skeleton = [];
350
+ let nextScanId = 0;
351
+ const nodeToScanId = new Map();
352
+
353
+ const isVisible = (el) => {
354
+ if (!(el instanceof HTMLElement)) return false;
355
+ const style = window.getComputedStyle(el);
356
+ if (style.display === "none" || style.visibility === "hidden") return false;
357
+ const rect = el.getBoundingClientRect();
358
+ if (rect.width === 0 && rect.height === 0) return false;
359
+ return true;
360
+ };
361
+
362
+ const computeRole = (el) => {
363
+ const tag = el.tagName.toLowerCase();
364
+ const explicit = el.getAttribute("role");
365
+ if (explicit) return explicit;
366
+ if (tag === "input") {
367
+ const t = (el.getAttribute("type") || "text").toLowerCase();
368
+ return INPUT_ROLE_MAP[t] || "textbox";
369
+ }
370
+ if (tag === "select") return el.hasAttribute("multiple") ? "listbox" : "combobox";
371
+ return IMPLICIT_ROLES[tag] || null;
372
+ };
373
+
374
+ const computeName = (el) => {
375
+ return (
376
+ el.getAttribute("aria-label") ||
377
+ el.getAttribute("title") ||
378
+ el.getAttribute("placeholder") ||
379
+ el.getAttribute("alt") ||
380
+ (el.textContent || "").trim().slice(0, 100) ||
381
+ null
382
+ );
383
+ };
384
+
385
+ const getSkeletonAttrs = (el) => {
386
+ const attrs = {};
387
+ for (const name of SKELETON_ATTRS) {
388
+ const val = el.getAttribute(name);
389
+ if (val != null) attrs[name] = val;
390
+ }
391
+ return attrs;
392
+ };
393
+
394
+ const shouldInclude = () => true;
395
+
396
+ /**
397
+ * Walk the DOM. Returns the list of nearest included descendant scanIds
398
+ * that should be added to the parent's childScanIds (bubbles up through
399
+ * non-included nodes so logical parent-child relationships are preserved).
400
+ */
401
+ const walk = (el, nearestAncestorScanId) => {
402
+ if (!(el instanceof HTMLElement)) return [];
403
+ const tag = el.tagName.toLowerCase();
404
+ if (SKIP_TAGS.has(tag)) return [];
405
+ if (!isVisible(el)) return [];
406
+
407
+ const include = shouldInclude(el);
408
+ let myScanId = null;
409
+
410
+ if (include) {
411
+ myScanId = nextScanId++;
412
+ nodeToScanId.set(el, myScanId);
413
+ }
414
+
415
+ const childRoot = el.shadowRoot && el.shadowRoot.mode === "open" ? el.shadowRoot : el;
416
+ const nearestDescendants = [];
417
+
418
+ for (const child of childRoot.children) {
419
+ if (!(child instanceof HTMLElement)) continue;
420
+ const childDescendants = walk(child, myScanId !== null ? myScanId : nearestAncestorScanId);
421
+ const childScanId = nodeToScanId.get(child);
422
+ if (childScanId !== undefined) {
423
+ nearestDescendants.push(childScanId);
424
+ } else {
425
+ nearestDescendants.push(...childDescendants);
426
+ }
427
+ }
428
+
429
+ if (include) {
430
+ const rect = el.getBoundingClientRect();
431
+ skeleton.push({
432
+ scanId: myScanId,
433
+ tagName: tag,
434
+ role: computeRole(el),
435
+ name: computeName(el),
436
+ text: (el.textContent || "").trim().slice(0, 200),
437
+ rect: {
438
+ x: Math.round(rect.x),
439
+ y: Math.round(rect.y),
440
+ width: Math.round(rect.width),
441
+ height: Math.round(rect.height)
442
+ },
443
+ attributes: getSkeletonAttrs(el),
444
+ parentScanId: nearestAncestorScanId,
445
+ childScanIds: nearestDescendants,
446
+ interactable: SKELETON_INTERACTABLE_TAGS.has(tag) || el.hasAttribute("role"),
447
+ state: captureElementState(el)
448
+ });
449
+ return [myScanId];
450
+ }
451
+
452
+ return nearestDescendants;
453
+ };
454
+
455
+ if (document.body) {
456
+ walk(document.body, null);
457
+ }
458
+
459
+ return {
460
+ url: window.location.href,
461
+ title: document.title,
462
+ capturedAt: new Date().toISOString(),
463
+ devicePixelRatio: window.devicePixelRatio || 1,
464
+ skeleton,
465
+ pageText: collectPageText(),
466
+ pageState: capturePageState()
467
+ };
468
+ };
469
+
470
+ /**
471
+ * Message listener — responds to scan commands from background.
472
+ */
473
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
474
+ if (!message || message.source !== "background") {
475
+ return false;
476
+ }
477
+
478
+ if (message.command === "discovery_scan") {
479
+ try {
480
+ const snapshot = runDiscoveryScan();
481
+ sendResponse({
482
+ ok: true,
483
+ snapshot
484
+ });
485
+ } catch (error) {
486
+ sendResponse({
487
+ ok: false,
488
+ error: error instanceof Error ? error.message : "discovery_scan_failed"
489
+ });
490
+ }
491
+ return false;
492
+ }
493
+
494
+ return false;
495
+ });