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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/cli/api/bridge.js +64 -0
- package/cli/api/openapi.js +175 -0
- package/cli/api/router.js +280 -0
- package/cli/api/swagger-ui.js +26 -0
- package/cli/discovery/classify.js +304 -0
- package/cli/discovery/compile.js +392 -0
- package/cli/discovery/enrich.js +376 -0
- package/cli/discovery/entities.js +356 -0
- package/cli/discovery/llm-client.js +352 -0
- package/cli/discovery/locators.js +326 -0
- package/cli/discovery/perceive.js +476 -0
- package/cli/discovery/session.js +930 -0
- package/cli/discovery/synthesize-workflows.js +295 -0
- package/cli/index.js +63 -0
- package/cli/manifest-store.js +140 -0
- package/cli/server.js +539 -0
- package/extension/background.js +1512 -0
- package/extension/content-script.js +491 -0
- package/extension/discovery.js +495 -0
- package/extension/executor.js +392 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +33 -0
- package/extension/shared/protocol.js +50 -0
- package/extension/sidepanel.html +277 -0
- package/extension/sidepanel.js +211 -0
- package/extension/vendor/LICENSE +22 -0
- package/extension/vendor/rrweb-record.min.js +84 -0
- package/package.json +49 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* locators.js — Stage 5: Locator Synthesis
|
|
3
|
+
*
|
|
4
|
+
* Runs on the CLI server. For each interactable element, generates one or more
|
|
5
|
+
* locator strategies compatible with LocatorStrategyDef from contract-dsl.
|
|
6
|
+
*
|
|
7
|
+
* Strategy priority: data_testid > role_name > attribute > text > css > dom_path > xpath
|
|
8
|
+
*
|
|
9
|
+
* @typedef {{ scanId: number, strategies: Array<{ kind: string, value: string, confidence: number }> }} LocatorCandidate
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_STRATEGIES_PER_ELEMENT = 5;
|
|
13
|
+
|
|
14
|
+
// Patterns for detecting auto-generated CSS classes
|
|
15
|
+
const DYNAMIC_CLASS_PATTERNS = [
|
|
16
|
+
/^css-/, // CSS modules, emotion
|
|
17
|
+
/^sc-/, // styled-components
|
|
18
|
+
/^emotion-/, // emotion
|
|
19
|
+
/^styled-/, // styled-components
|
|
20
|
+
/^_[a-zA-Z0-9]{5,}/, // underscore + hash
|
|
21
|
+
/^[a-z]{1,3}[A-Z0-9][a-zA-Z0-9]{4,}$/, // camelCase hash (e.g., bdfBwQ)
|
|
22
|
+
/^[a-zA-Z]+-[a-f0-9]{4,}$/, // prefix-hash (e.g., module-3a2b1c)
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Attributes considered stable for locator generation
|
|
26
|
+
const STABLE_ATTRIBUTES = ["name", "type", "href", "placeholder", "aria-label", "for", "id", "action", "method"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a CSS class looks auto-generated.
|
|
30
|
+
*/
|
|
31
|
+
const isDynamicClass = (cls) => {
|
|
32
|
+
return DYNAMIC_CLASS_PATTERNS.some((pattern) => pattern.test(cls));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build parent chain from an element up to the root.
|
|
37
|
+
* Returns array of scanIds from root to element (inclusive).
|
|
38
|
+
*/
|
|
39
|
+
const buildParentChain = (scanId, elementMap) => {
|
|
40
|
+
const chain = [];
|
|
41
|
+
let current = scanId;
|
|
42
|
+
|
|
43
|
+
while (current !== null && current !== undefined) {
|
|
44
|
+
chain.unshift(current);
|
|
45
|
+
const el = elementMap.get(current);
|
|
46
|
+
current = el?.parentScanId ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return chain;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the nth-child index of an element among its parent's children.
|
|
54
|
+
*/
|
|
55
|
+
const getNthChildIndex = (el, elementMap) => {
|
|
56
|
+
if (el.parentScanId === null) return 1;
|
|
57
|
+
|
|
58
|
+
const parent = elementMap.get(el.parentScanId);
|
|
59
|
+
if (!parent) return 1;
|
|
60
|
+
|
|
61
|
+
const sameTagSiblings = parent.childScanIds.filter((cid) => {
|
|
62
|
+
const sibling = elementMap.get(cid);
|
|
63
|
+
return sibling && sibling.tagName === el.tagName;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (sameTagSiblings.length <= 1) return 0; // No need for nth-child
|
|
67
|
+
|
|
68
|
+
return sameTagSiblings.indexOf(el.scanId) + 1;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate a dom_path locator: tag path from body with nth-child qualifiers.
|
|
73
|
+
* e.g., "body > main > div:nth-child(2) > button"
|
|
74
|
+
*/
|
|
75
|
+
const generateDomPath = (scanId, elementMap) => {
|
|
76
|
+
const chain = buildParentChain(scanId, elementMap);
|
|
77
|
+
const parts = [];
|
|
78
|
+
|
|
79
|
+
for (const sid of chain) {
|
|
80
|
+
const el = elementMap.get(sid);
|
|
81
|
+
if (!el) continue;
|
|
82
|
+
|
|
83
|
+
let segment = el.tagName;
|
|
84
|
+
const nth = getNthChildIndex(el, elementMap);
|
|
85
|
+
if (nth > 0) {
|
|
86
|
+
segment += `:nth-child(${nth})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
parts.push(segment);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return parts.join(" > ");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate an XPath locator.
|
|
97
|
+
* e.g., "/html/body/main/div[2]/button[1]"
|
|
98
|
+
*/
|
|
99
|
+
const generateXPath = (scanId, elementMap) => {
|
|
100
|
+
const chain = buildParentChain(scanId, elementMap);
|
|
101
|
+
const parts = [];
|
|
102
|
+
|
|
103
|
+
for (const sid of chain) {
|
|
104
|
+
const el = elementMap.get(sid);
|
|
105
|
+
if (!el) continue;
|
|
106
|
+
|
|
107
|
+
let segment = el.tagName;
|
|
108
|
+
const nth = getNthChildIndex(el, elementMap);
|
|
109
|
+
if (nth > 0) {
|
|
110
|
+
segment += `[${nth}]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
parts.push(segment);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return "/" + parts.join("/");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate a CSS selector from stable attributes.
|
|
121
|
+
* Avoids dynamic classes.
|
|
122
|
+
*/
|
|
123
|
+
const generateCssSelector = (el) => {
|
|
124
|
+
const parts = [el.tagName];
|
|
125
|
+
|
|
126
|
+
// Try ID first (most specific)
|
|
127
|
+
const id = el.attributes.id;
|
|
128
|
+
if (id && !isDynamicClass(id)) {
|
|
129
|
+
return `#${CSS_escape(id)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Try stable classes
|
|
133
|
+
const classAttr = el.attributes.class;
|
|
134
|
+
if (classAttr) {
|
|
135
|
+
const classes = classAttr.split(/\s+/).filter((c) => c && !isDynamicClass(c));
|
|
136
|
+
if (classes.length > 0) {
|
|
137
|
+
// Use up to 2 stable classes
|
|
138
|
+
const usable = classes.slice(0, 2);
|
|
139
|
+
parts.push(...usable.map((c) => `.${CSS_escape(c)}`));
|
|
140
|
+
return parts.join("");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Try type attribute for inputs
|
|
145
|
+
if (el.tagName === "input" && el.attributes.type) {
|
|
146
|
+
return `input[type="${el.attributes.type}"]`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Bare tag name (low uniqueness)
|
|
150
|
+
return el.tagName;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Escape a string for use in CSS selectors.
|
|
155
|
+
*/
|
|
156
|
+
const CSS_escape = (str) => {
|
|
157
|
+
return str.replace(/([^\w-])/g, "\\$1");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a uniqueness index: for each potential locator value, how many
|
|
162
|
+
* elements match it.
|
|
163
|
+
*/
|
|
164
|
+
const buildUniquenessIndex = (elements, a11yMap) => {
|
|
165
|
+
const counts = {
|
|
166
|
+
testid: new Map(),
|
|
167
|
+
roleName: new Map(),
|
|
168
|
+
text: new Map(),
|
|
169
|
+
id: new Map()
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const el of elements) {
|
|
173
|
+
const testid = el.attributes["data-testid"];
|
|
174
|
+
if (testid) {
|
|
175
|
+
counts.testid.set(testid, (counts.testid.get(testid) || 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const a11y = a11yMap.get(el.scanId);
|
|
179
|
+
if (a11y?.role && a11y?.name) {
|
|
180
|
+
const key = `${a11y.role}:${a11y.name}`;
|
|
181
|
+
counts.roleName.set(key, (counts.roleName.get(key) || 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (el.textContent) {
|
|
185
|
+
const text = el.textContent.trim().slice(0, 100);
|
|
186
|
+
if (text) {
|
|
187
|
+
counts.text.set(text, (counts.text.get(text) || 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const id = el.attributes.id;
|
|
192
|
+
if (id) {
|
|
193
|
+
counts.id.set(id, (counts.id.get(id) || 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return counts;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Synthesize locator strategies for a single interactable element.
|
|
202
|
+
*/
|
|
203
|
+
const synthesizeLocators = (el, a11y, elementMap, uniqueness) => {
|
|
204
|
+
const strategies = [];
|
|
205
|
+
|
|
206
|
+
// 1. data_testid (confidence: 0.95)
|
|
207
|
+
const testid = el.attributes["data-testid"];
|
|
208
|
+
if (testid) {
|
|
209
|
+
const count = uniqueness.testid.get(testid) || 1;
|
|
210
|
+
const conf = count === 1 ? 0.95 : 0.95 / count;
|
|
211
|
+
strategies.push({ kind: "data_testid", value: testid, confidence: conf });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 2. role_name (confidence: 0.90)
|
|
215
|
+
if (a11y?.role && a11y?.name && a11y.name.trim()) {
|
|
216
|
+
const key = `${a11y.role}:${a11y.name}`;
|
|
217
|
+
const count = uniqueness.roleName.get(key) || 1;
|
|
218
|
+
const value = `${a11y.role} "${a11y.name.trim()}"`;
|
|
219
|
+
const conf = count === 1 ? 0.90 : 0.90 / count;
|
|
220
|
+
strategies.push({ kind: "role_name", value, confidence: conf });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. attribute — stable attributes (confidence: 0.80)
|
|
224
|
+
for (const attr of STABLE_ATTRIBUTES) {
|
|
225
|
+
const val = el.attributes[attr];
|
|
226
|
+
if (!val) continue;
|
|
227
|
+
// Skip if we already used this attr in another strategy
|
|
228
|
+
if (attr === "aria-label" && a11y?.name) continue;
|
|
229
|
+
if (attr === "id") {
|
|
230
|
+
const count = uniqueness.id.get(val) || 1;
|
|
231
|
+
const conf = count === 1 ? 0.85 : 0.85 / count;
|
|
232
|
+
strategies.push({ kind: "attribute", value: `id:${val}`, confidence: conf });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
strategies.push({ kind: "attribute", value: `${attr}:${val}`, confidence: 0.80 });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 4. text (confidence: 0.70)
|
|
239
|
+
if (el.textContent) {
|
|
240
|
+
const text = el.textContent.trim().slice(0, 100);
|
|
241
|
+
if (text) {
|
|
242
|
+
const count = uniqueness.text.get(text) || 1;
|
|
243
|
+
const conf = count === 1 ? 0.70 : 0.70 / count;
|
|
244
|
+
strategies.push({ kind: "text", value: text, confidence: conf });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 5. css (confidence: 0.60)
|
|
249
|
+
const css = generateCssSelector(el);
|
|
250
|
+
if (css !== el.tagName) { // Only emit if more specific than bare tag
|
|
251
|
+
strategies.push({ kind: "css", value: css, confidence: 0.60 });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 6. dom_path (confidence: 0.40)
|
|
255
|
+
const domPath = generateDomPath(el.scanId, elementMap);
|
|
256
|
+
if (domPath) {
|
|
257
|
+
strategies.push({ kind: "dom_path", value: domPath, confidence: 0.40 });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 7. xpath (confidence: 0.30)
|
|
261
|
+
const xpath = generateXPath(el.scanId, elementMap);
|
|
262
|
+
if (xpath) {
|
|
263
|
+
strategies.push({ kind: "xpath", value: xpath, confidence: 0.30 });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Sort by confidence desc, take top N
|
|
267
|
+
strategies.sort((a, b) => b.confidence - a.confidence);
|
|
268
|
+
return strategies.slice(0, MAX_STRATEGIES_PER_ELEMENT);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Synthesize locators for all interactable elements.
|
|
273
|
+
*
|
|
274
|
+
* @param {Array} elements - ScannedElement[]
|
|
275
|
+
* @param {Array} a11yEntries - A11yInfo[]
|
|
276
|
+
* @param {Array} interactables - InteractableElement[]
|
|
277
|
+
* @returns {{ locators: LocatorCandidate[], stats: { total: number, avgStrategies: number, byKind: Record<string, number> } }}
|
|
278
|
+
*/
|
|
279
|
+
export const synthesizeAllLocators = (elements, a11yEntries, interactables) => {
|
|
280
|
+
const elementMap = new Map();
|
|
281
|
+
for (const el of elements) {
|
|
282
|
+
elementMap.set(el.scanId, el);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const a11yMap = new Map();
|
|
286
|
+
for (const entry of a11yEntries) {
|
|
287
|
+
a11yMap.set(entry.scanId, entry);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const uniqueness = buildUniquenessIndex(elements, a11yMap);
|
|
291
|
+
|
|
292
|
+
const interactableSet = new Set(interactables.map((i) => i.scanId));
|
|
293
|
+
|
|
294
|
+
const locators = [];
|
|
295
|
+
const byKind = {};
|
|
296
|
+
let totalStrategies = 0;
|
|
297
|
+
|
|
298
|
+
for (const item of interactables) {
|
|
299
|
+
const el = elementMap.get(item.scanId);
|
|
300
|
+
if (!el) continue;
|
|
301
|
+
|
|
302
|
+
const a11y = a11yMap.get(item.scanId) || null;
|
|
303
|
+
const strategies = synthesizeLocators(el, a11y, elementMap, uniqueness);
|
|
304
|
+
|
|
305
|
+
if (strategies.length > 0) {
|
|
306
|
+
locators.push({
|
|
307
|
+
scanId: item.scanId,
|
|
308
|
+
strategies
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
totalStrategies += strategies.length;
|
|
312
|
+
for (const s of strategies) {
|
|
313
|
+
byKind[s.kind] = (byKind[s.kind] || 0) + 1;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
locators,
|
|
320
|
+
stats: {
|
|
321
|
+
total: locators.length,
|
|
322
|
+
avgStrategies: locators.length > 0 ? +(totalStrategies / locators.length).toFixed(1) : 0,
|
|
323
|
+
byKind
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
};
|