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,392 @@
1
+ /**
2
+ * executor.js — Action executor for the SDK
3
+ *
4
+ * Resolves locator strategies against the live DOM and executes actions
5
+ * (click, type, select) or reads element state.
6
+ *
7
+ * Injected as a content script alongside discovery.js.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Locator resolution
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Try each locator strategy in order, return the first that uniquely matches.
16
+ * @param {Array<{kind: string, value: string, confidence: number}>} strategies
17
+ * @returns {{ element: Element, usedStrategy: { kind: string, value: string } } | null}
18
+ */
19
+ const resolveLocator = (strategies) => {
20
+ // Sort by confidence descending
21
+ const sorted = [...strategies].sort((a, b) => b.confidence - a.confidence);
22
+
23
+ for (const strategy of sorted) {
24
+ const result = tryStrategy(strategy);
25
+ if (result) {
26
+ return { element: result, usedStrategy: { kind: strategy.kind, value: strategy.value } };
27
+ }
28
+ }
29
+
30
+ return null;
31
+ };
32
+
33
+ /**
34
+ * Try a single locator strategy. Returns the element if exactly one matches.
35
+ */
36
+ const tryStrategy = (strategy) => {
37
+ try {
38
+ switch (strategy.kind) {
39
+ case "css":
40
+ case "dom_path":
41
+ return tryCSS(strategy.value);
42
+
43
+ case "xpath":
44
+ return tryXPath(strategy.value);
45
+
46
+ case "role_name":
47
+ return tryRoleName(strategy.value);
48
+
49
+ case "attribute":
50
+ return tryAttribute(strategy.value);
51
+
52
+ case "text":
53
+ return tryText(strategy.value);
54
+
55
+ default:
56
+ return null;
57
+ }
58
+ } catch {
59
+ return null;
60
+ }
61
+ };
62
+
63
+ const tryCSS = (selector) => {
64
+ const matches = document.querySelectorAll(selector);
65
+ if (matches.length === 1) return matches[0];
66
+ // For dom_path-style selectors, accept first visible match
67
+ if (matches.length > 1) {
68
+ for (const el of matches) {
69
+ if (isElementVisible(el)) return el;
70
+ }
71
+ }
72
+ return null;
73
+ };
74
+
75
+ const tryXPath = (xpath) => {
76
+ // Prefix with /html if it starts with /body
77
+ const fullXpath = xpath.startsWith("/body") ? `/html${xpath}` : xpath;
78
+ const result = document.evaluate(
79
+ fullXpath, document, null,
80
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null
81
+ );
82
+ if (result.snapshotLength === 1) return result.snapshotItem(0);
83
+ if (result.snapshotLength > 1) {
84
+ for (let i = 0; i < result.snapshotLength; i++) {
85
+ const el = result.snapshotItem(i);
86
+ if (isElementVisible(el)) return el;
87
+ }
88
+ }
89
+ return null;
90
+ };
91
+
92
+ const tryRoleName = (value) => {
93
+ // Parse "role \"accessible name\""
94
+ const match = value.match(/^(\w+)\s+"(.+)"$/);
95
+ if (!match) return null;
96
+ const [, role, name] = match;
97
+
98
+ // Walk the DOM looking for matching role + name
99
+ const candidates = document.querySelectorAll("*");
100
+ let found = null;
101
+ let count = 0;
102
+
103
+ for (const el of candidates) {
104
+ const elRole = el.getAttribute("role") || getImplicitRole(el);
105
+ if (elRole !== role) continue;
106
+
107
+ const elName = getAccessibleName(el);
108
+ if (elName === name) {
109
+ found = el;
110
+ count++;
111
+ if (count > 1) return null; // Ambiguous
112
+ }
113
+ }
114
+
115
+ return found;
116
+ };
117
+
118
+ const tryAttribute = (value) => {
119
+ const colonIdx = value.indexOf(":");
120
+ if (colonIdx === -1) return null;
121
+ const attr = value.slice(0, colonIdx);
122
+ const attrVal = value.slice(colonIdx + 1);
123
+
124
+ const matches = document.querySelectorAll(`[${attr}="${CSS.escape(attrVal)}"]`);
125
+ if (matches.length === 1) return matches[0];
126
+ if (matches.length > 1) {
127
+ for (const el of matches) {
128
+ if (isElementVisible(el)) return el;
129
+ }
130
+ }
131
+ return null;
132
+ };
133
+
134
+ const tryText = (value) => {
135
+ const walker = document.createTreeWalker(
136
+ document.body, NodeFilter.SHOW_TEXT,
137
+ {
138
+ acceptNode(node) {
139
+ const text = (node.textContent || "").trim();
140
+ if (text === value) return NodeFilter.FILTER_ACCEPT;
141
+ return NodeFilter.FILTER_REJECT;
142
+ }
143
+ }
144
+ );
145
+
146
+ const textNode = walker.nextNode();
147
+ if (!textNode) return null;
148
+ // Check no second match
149
+ if (walker.nextNode()) return null;
150
+ return textNode.parentElement;
151
+ };
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Helpers
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const IMPLICIT_ROLES = {
158
+ button: "button", a: "link", textarea: "textbox",
159
+ nav: "navigation", main: "main", form: "form",
160
+ table: "table", ul: "list", ol: "list", li: "listitem",
161
+ dialog: "dialog", article: "article", section: "region",
162
+ aside: "complementary", header: "banner", footer: "contentinfo",
163
+ select: "combobox", details: "group", summary: "button"
164
+ };
165
+
166
+ const INPUT_ROLES = {
167
+ text: "textbox", search: "searchbox", email: "textbox",
168
+ tel: "textbox", url: "textbox", password: "textbox",
169
+ number: "spinbutton", range: "slider",
170
+ checkbox: "checkbox", radio: "radio",
171
+ button: "button", submit: "button", reset: "button"
172
+ };
173
+
174
+ const getImplicitRole = (el) => {
175
+ const tag = el.tagName.toLowerCase();
176
+ if (tag === "input") {
177
+ return INPUT_ROLES[(el.type || "text").toLowerCase()] || null;
178
+ }
179
+ if (tag.match(/^h[1-6]$/)) return "heading";
180
+ return IMPLICIT_ROLES[tag] || null;
181
+ };
182
+
183
+ const getAccessibleName = (el) => {
184
+ return (
185
+ el.getAttribute("aria-label") ||
186
+ el.getAttribute("title") ||
187
+ el.getAttribute("alt") ||
188
+ el.getAttribute("placeholder") ||
189
+ el.textContent?.trim().slice(0, 100) ||
190
+ ""
191
+ );
192
+ };
193
+
194
+ const isElementVisible = (el) => {
195
+ if (!(el instanceof HTMLElement)) return false;
196
+ const style = window.getComputedStyle(el);
197
+ if (style.display === "none" || style.visibility === "hidden") return false;
198
+ const rect = el.getBoundingClientRect();
199
+ return rect.width > 0 && rect.height > 0;
200
+ };
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Action execution
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Execute an action on a resolved element.
208
+ */
209
+ const executeAction = (element, interactionKind, inputs = {}) => {
210
+ const kind = (interactionKind || "click").toLowerCase();
211
+
212
+ switch (kind) {
213
+ case "click":
214
+ case "navigate":
215
+ element.click();
216
+ return { ok: true, action: "clicked" };
217
+
218
+ case "type": {
219
+ const text = inputs.text || inputs.value || Object.values(inputs)[0] || "";
220
+ element.focus();
221
+ // Clear existing value
222
+ if ("value" in element) {
223
+ element.value = "";
224
+ }
225
+ // Dispatch input events character by character for framework compatibility
226
+ for (const char of text) {
227
+ element.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
228
+ if ("value" in element) {
229
+ element.value += char;
230
+ }
231
+ element.dispatchEvent(new InputEvent("input", { data: char, inputType: "insertText", bubbles: true }));
232
+ element.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
233
+ }
234
+ element.dispatchEvent(new Event("change", { bubbles: true }));
235
+ return { ok: true, action: "typed", length: text.length };
236
+ }
237
+
238
+ case "select": {
239
+ const value = inputs.value || inputs.selected_option || Object.values(inputs)[0] || "";
240
+ if ("value" in element) {
241
+ element.value = value;
242
+ element.dispatchEvent(new Event("change", { bubbles: true }));
243
+ element.dispatchEvent(new Event("input", { bubbles: true }));
244
+ }
245
+ return { ok: true, action: "selected", value };
246
+ }
247
+
248
+ default:
249
+ // Fallback to click
250
+ element.click();
251
+ return { ok: true, action: "clicked" };
252
+ }
253
+ };
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // State reading
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Read the state of a resolved element.
261
+ */
262
+ const readElementState = (element) => {
263
+ const rect = element.getBoundingClientRect();
264
+ const style = window.getComputedStyle(element);
265
+ const tag = element.tagName.toLowerCase();
266
+
267
+ return {
268
+ visible: isElementVisible(element),
269
+ tag,
270
+ text: (element.textContent || "").trim().slice(0, 500),
271
+ value: "value" in element ? element.value : undefined,
272
+ checked: "checked" in element ? element.checked : undefined,
273
+ disabled: element.disabled || element.getAttribute("aria-disabled") === "true",
274
+ attributes: Object.fromEntries(
275
+ Array.from(element.attributes).map((a) => [a.name, a.value])
276
+ ),
277
+ rect: {
278
+ x: Math.round(rect.x),
279
+ y: Math.round(rect.y),
280
+ width: Math.round(rect.width),
281
+ height: Math.round(rect.height)
282
+ },
283
+ role: element.getAttribute("role") || getImplicitRole(element),
284
+ name: getAccessibleName(element)
285
+ };
286
+ };
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Message listener
290
+ // ---------------------------------------------------------------------------
291
+
292
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
293
+ if (!message || message.source !== "background") return false;
294
+
295
+ if (message.command === "execute_action") {
296
+ try {
297
+ const { strategies, interactionKind, inputs } = message.payload;
298
+ const resolved = resolveLocator(strategies || []);
299
+
300
+ if (!resolved) {
301
+ sendResponse({
302
+ ok: false,
303
+ error: "ERR_TARGET_NOT_FOUND",
304
+ message: "No locator strategy matched a unique element"
305
+ });
306
+ return false;
307
+ }
308
+
309
+ const result = executeAction(resolved.element, interactionKind, inputs);
310
+ sendResponse({
311
+ ok: true,
312
+ result,
313
+ usedStrategy: resolved.usedStrategy
314
+ });
315
+ } catch (error) {
316
+ sendResponse({
317
+ ok: false,
318
+ error: "ERR_EXECUTION_FAILED",
319
+ message: error instanceof Error ? error.message : "Unknown error"
320
+ });
321
+ }
322
+ return false;
323
+ }
324
+
325
+ if (message.command === "evaluate_state_signals") {
326
+ try {
327
+ const { signals } = message.payload;
328
+ const results = {};
329
+ for (const signal of (signals || [])) {
330
+ const key = `${signal.kind}:${signal.value}`;
331
+ try {
332
+ if (signal.kind === "selector_exists") {
333
+ results[key] = document.querySelector(signal.value) !== null;
334
+ } else if (signal.kind === "text_match") {
335
+ const el = signal.selector ? document.querySelector(signal.selector) : document.body;
336
+ if (el) {
337
+ results[key] = new RegExp(signal.value).test((el.textContent || "").trim());
338
+ } else {
339
+ results[key] = false;
340
+ }
341
+ } else if (signal.kind === "url_pattern") {
342
+ results[key] = new RegExp(signal.value).test(window.location.pathname);
343
+ } else {
344
+ results[key] = false;
345
+ }
346
+ } catch {
347
+ results[key] = false;
348
+ }
349
+ }
350
+ sendResponse({ ok: true, results });
351
+ } catch (error) {
352
+ sendResponse({
353
+ ok: false,
354
+ error: "ERR_SIGNAL_EVAL_FAILED",
355
+ message: error instanceof Error ? error.message : "Unknown error"
356
+ });
357
+ }
358
+ return false;
359
+ }
360
+
361
+ if (message.command === "read_entity") {
362
+ try {
363
+ const { strategies } = message.payload;
364
+ const resolved = resolveLocator(strategies || []);
365
+
366
+ if (!resolved) {
367
+ sendResponse({
368
+ ok: false,
369
+ error: "ERR_TARGET_NOT_FOUND",
370
+ message: "No locator strategy matched a unique element"
371
+ });
372
+ return false;
373
+ }
374
+
375
+ const state = readElementState(resolved.element);
376
+ sendResponse({
377
+ ok: true,
378
+ state,
379
+ usedStrategy: resolved.usedStrategy
380
+ });
381
+ } catch (error) {
382
+ sendResponse({
383
+ ok: false,
384
+ error: "ERR_READ_FAILED",
385
+ message: error instanceof Error ? error.message : "Unknown error"
386
+ });
387
+ }
388
+ return false;
389
+ }
390
+
391
+ return false;
392
+ });
Binary file
Binary file
Binary file
@@ -0,0 +1,33 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "BrowserWire",
4
+ "description": "Boilerplate side panel extension for BrowserWire local connectivity.",
5
+ "version": "0.1.0",
6
+ "permissions": ["sidePanel", "tabs", "scripting", "activeTab", "webRequest"],
7
+ "host_permissions": ["<all_urls>"],
8
+ "background": {
9
+ "service_worker": "background.js",
10
+ "type": "module"
11
+ },
12
+ "content_scripts": [
13
+ {
14
+ "matches": ["http://*/*", "https://*/*"],
15
+ "js": ["vendor/rrweb-record.min.js", "content-script.js"],
16
+ "run_at": "document_start"
17
+ },
18
+ {
19
+ "matches": ["http://*/*", "https://*/*"],
20
+ "js": ["discovery.js", "executor.js"],
21
+ "run_at": "document_idle"
22
+ }
23
+ ],
24
+ "action": {
25
+ "default_title": "Open BrowserWire"
26
+ },
27
+ "side_panel": {
28
+ "default_path": "sidepanel.html"
29
+ },
30
+ "content_security_policy": {
31
+ "extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* ws://localhost:*;"
32
+ }
33
+ }
@@ -0,0 +1,50 @@
1
+ export const PROTOCOL_VERSION = "0.1.0";
2
+
3
+ export const MessageType = Object.freeze({
4
+ HELLO: "hello",
5
+ HELLO_ACK: "hello_ack",
6
+ PING: "ping",
7
+ PONG: "pong",
8
+ STATUS: "status",
9
+ ERROR: "error",
10
+ DISCOVERY_SCAN: "discovery_scan",
11
+ DISCOVERY_SNAPSHOT: "discovery_snapshot",
12
+ DISCOVERY_ACK: "discovery_ack",
13
+ DISCOVERY_SESSION_START: "discovery_session_start",
14
+ DISCOVERY_SESSION_STOP: "discovery_session_stop",
15
+ DISCOVERY_INCREMENTAL: "discovery_incremental",
16
+ DISCOVERY_SESSION_STATUS: "discovery_session_status",
17
+ EXECUTE_ACTION: "execute_action",
18
+ EXECUTE_RESULT: "execute_result",
19
+ READ_ENTITY: "read_entity",
20
+ READ_RESULT: "read_result",
21
+ MANIFEST_READY: "manifest_ready",
22
+ CHECKPOINT: "checkpoint",
23
+ CHECKPOINT_COMPLETE: "checkpoint_complete",
24
+ EXECUTE_WORKFLOW: "execute_workflow",
25
+ WORKFLOW_RESULT: "workflow_result"
26
+ });
27
+
28
+ export const createEnvelope = (type, payload = {}, requestId) => ({
29
+ type,
30
+ payload,
31
+ requestId
32
+ });
33
+
34
+ export const parseEnvelope = (raw) => {
35
+ if (typeof raw !== "string") {
36
+ return null;
37
+ }
38
+
39
+ try {
40
+ const parsed = JSON.parse(raw);
41
+
42
+ if (!parsed || typeof parsed.type !== "string") {
43
+ return null;
44
+ }
45
+
46
+ return parsed;
47
+ } catch {
48
+ return null;
49
+ }
50
+ };