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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify.js — Stage 3: Interactable Classification
|
|
3
|
+
*
|
|
4
|
+
* Runs on the CLI server. Takes raw ScannedElement[] + A11yInfo[] from
|
|
5
|
+
* Stages 1–2 and classifies which elements are interactable and what
|
|
6
|
+
* kind of interaction they afford.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {{ scanId: number, interactionKind: string, confidence: number, inputType?: string }} InteractableElement
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Input types that afford "type" interaction
|
|
12
|
+
const TYPEABLE_INPUT_TYPES = new Set([
|
|
13
|
+
"text", "email", "password", "search", "tel", "url", "number", "date",
|
|
14
|
+
"datetime-local", "month", "time", "week", "color"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Input types that afford "toggle" interaction
|
|
18
|
+
const TOGGLE_INPUT_TYPES = new Set(["checkbox", "radio"]);
|
|
19
|
+
|
|
20
|
+
// Input types that afford "click" (button-like) interaction
|
|
21
|
+
const BUTTON_INPUT_TYPES = new Set(["button", "reset", "image"]);
|
|
22
|
+
|
|
23
|
+
// Roles that map to "click"
|
|
24
|
+
const CLICK_ROLES = new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "treeitem"]);
|
|
25
|
+
|
|
26
|
+
// Roles that map to "navigate"
|
|
27
|
+
const NAVIGATE_ROLES = new Set(["link"]);
|
|
28
|
+
|
|
29
|
+
// Roles that map to "type"
|
|
30
|
+
const TYPE_ROLES = new Set(["textbox", "searchbox", "spinbutton"]);
|
|
31
|
+
|
|
32
|
+
// Roles that map to "toggle"
|
|
33
|
+
const TOGGLE_ROLES = new Set(["checkbox", "radio", "switch"]);
|
|
34
|
+
|
|
35
|
+
// Roles that map to "select"
|
|
36
|
+
const SELECT_ROLES = new Set(["combobox", "listbox"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Classify a single element as interactable or not.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ tagName: string, attributes: Record<string, string>, textContent: string }} element
|
|
42
|
+
* @param {{ role: string | null, name: string | null, isDisabled: boolean }} a11y
|
|
43
|
+
* @returns {InteractableElement | null} classification result, or null if not interactable
|
|
44
|
+
*/
|
|
45
|
+
const classifyElement = (element, a11y) => {
|
|
46
|
+
const tag = element.tagName;
|
|
47
|
+
const attrs = element.attributes;
|
|
48
|
+
const role = a11y?.role || null;
|
|
49
|
+
const isDisabled = a11y?.isDisabled || false;
|
|
50
|
+
|
|
51
|
+
// Disabled elements are still classified but with reduced confidence
|
|
52
|
+
const disabledPenalty = isDisabled ? 0.3 : 0;
|
|
53
|
+
|
|
54
|
+
// --- Tag-based classification (highest confidence) ---
|
|
55
|
+
|
|
56
|
+
// <button> or <summary> → click (unless type="submit")
|
|
57
|
+
if (tag === "button") {
|
|
58
|
+
const buttonType = (attrs.type || "").toLowerCase();
|
|
59
|
+
if (buttonType === "submit") {
|
|
60
|
+
return {
|
|
61
|
+
scanId: element.scanId,
|
|
62
|
+
interactionKind: "submit",
|
|
63
|
+
confidence: 0.95 - disabledPenalty
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
scanId: element.scanId,
|
|
68
|
+
interactionKind: "click",
|
|
69
|
+
confidence: 0.95 - disabledPenalty
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// <a> with href → navigate
|
|
74
|
+
if (tag === "a" && "href" in attrs) {
|
|
75
|
+
return {
|
|
76
|
+
scanId: element.scanId,
|
|
77
|
+
interactionKind: "navigate",
|
|
78
|
+
confidence: 0.95 - disabledPenalty
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// <input> → depends on type
|
|
83
|
+
if (tag === "input") {
|
|
84
|
+
const inputType = (attrs.type || "text").toLowerCase();
|
|
85
|
+
|
|
86
|
+
if (inputType === "submit") {
|
|
87
|
+
return {
|
|
88
|
+
scanId: element.scanId,
|
|
89
|
+
interactionKind: "submit",
|
|
90
|
+
confidence: 0.95 - disabledPenalty,
|
|
91
|
+
inputType
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (TYPEABLE_INPUT_TYPES.has(inputType)) {
|
|
96
|
+
return {
|
|
97
|
+
scanId: element.scanId,
|
|
98
|
+
interactionKind: "type",
|
|
99
|
+
confidence: 0.95 - disabledPenalty,
|
|
100
|
+
inputType
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (TOGGLE_INPUT_TYPES.has(inputType)) {
|
|
105
|
+
return {
|
|
106
|
+
scanId: element.scanId,
|
|
107
|
+
interactionKind: "toggle",
|
|
108
|
+
confidence: 0.95 - disabledPenalty,
|
|
109
|
+
inputType
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (BUTTON_INPUT_TYPES.has(inputType)) {
|
|
114
|
+
return {
|
|
115
|
+
scanId: element.scanId,
|
|
116
|
+
interactionKind: "click",
|
|
117
|
+
confidence: 0.90 - disabledPenalty,
|
|
118
|
+
inputType
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Hidden, file, range — not standard interactables for our purposes
|
|
123
|
+
if (inputType === "hidden") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (inputType === "file") {
|
|
128
|
+
return {
|
|
129
|
+
scanId: element.scanId,
|
|
130
|
+
interactionKind: "click",
|
|
131
|
+
confidence: 0.70 - disabledPenalty,
|
|
132
|
+
inputType
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (inputType === "range") {
|
|
137
|
+
return {
|
|
138
|
+
scanId: element.scanId,
|
|
139
|
+
interactionKind: "click",
|
|
140
|
+
confidence: 0.70 - disabledPenalty,
|
|
141
|
+
inputType: "range"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback for unknown input types
|
|
146
|
+
return {
|
|
147
|
+
scanId: element.scanId,
|
|
148
|
+
interactionKind: "type",
|
|
149
|
+
confidence: 0.50 - disabledPenalty,
|
|
150
|
+
inputType
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// <textarea> → type
|
|
155
|
+
if (tag === "textarea") {
|
|
156
|
+
return {
|
|
157
|
+
scanId: element.scanId,
|
|
158
|
+
interactionKind: "type",
|
|
159
|
+
confidence: 0.95 - disabledPenalty
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// <select> → select
|
|
164
|
+
if (tag === "select") {
|
|
165
|
+
return {
|
|
166
|
+
scanId: element.scanId,
|
|
167
|
+
interactionKind: "select",
|
|
168
|
+
confidence: 0.95 - disabledPenalty
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// <details> / <summary> → toggle
|
|
173
|
+
if (tag === "details" || tag === "summary") {
|
|
174
|
+
return {
|
|
175
|
+
scanId: element.scanId,
|
|
176
|
+
interactionKind: "toggle",
|
|
177
|
+
confidence: 0.85 - disabledPenalty
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Role-based classification (slightly lower confidence than tag-based) ---
|
|
182
|
+
|
|
183
|
+
if (role) {
|
|
184
|
+
if (role === "button" && tag !== "button") {
|
|
185
|
+
return {
|
|
186
|
+
scanId: element.scanId,
|
|
187
|
+
interactionKind: "click",
|
|
188
|
+
confidence: 0.85 - disabledPenalty
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (NAVIGATE_ROLES.has(role) && tag !== "a") {
|
|
193
|
+
return {
|
|
194
|
+
scanId: element.scanId,
|
|
195
|
+
interactionKind: "navigate",
|
|
196
|
+
confidence: 0.80 - disabledPenalty
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (TYPE_ROLES.has(role) && tag !== "input" && tag !== "textarea") {
|
|
201
|
+
return {
|
|
202
|
+
scanId: element.scanId,
|
|
203
|
+
interactionKind: "type",
|
|
204
|
+
confidence: 0.80 - disabledPenalty
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (TOGGLE_ROLES.has(role) && tag !== "input") {
|
|
209
|
+
return {
|
|
210
|
+
scanId: element.scanId,
|
|
211
|
+
interactionKind: "toggle",
|
|
212
|
+
confidence: 0.80 - disabledPenalty
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (SELECT_ROLES.has(role) && tag !== "select") {
|
|
217
|
+
return {
|
|
218
|
+
scanId: element.scanId,
|
|
219
|
+
interactionKind: "select",
|
|
220
|
+
confidence: 0.75 - disabledPenalty
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (CLICK_ROLES.has(role)) {
|
|
225
|
+
return {
|
|
226
|
+
scanId: element.scanId,
|
|
227
|
+
interactionKind: "click",
|
|
228
|
+
confidence: 0.80 - disabledPenalty
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Attribute-based classification (lower confidence) ---
|
|
234
|
+
|
|
235
|
+
// contenteditable → type
|
|
236
|
+
if (attrs.contenteditable === "true" || attrs.contenteditable === "") {
|
|
237
|
+
return {
|
|
238
|
+
scanId: element.scanId,
|
|
239
|
+
interactionKind: "type",
|
|
240
|
+
confidence: 0.75 - disabledPenalty
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// onclick attribute → click
|
|
245
|
+
if ("onclick" in attrs) {
|
|
246
|
+
return {
|
|
247
|
+
scanId: element.scanId,
|
|
248
|
+
interactionKind: "click",
|
|
249
|
+
confidence: 0.60 - disabledPenalty
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// tabindex (non-negative) on a non-interactive element suggests interactability
|
|
254
|
+
if ("tabindex" in attrs && tag !== "div" && tag !== "span") {
|
|
255
|
+
const tabindex = parseInt(attrs.tabindex, 10);
|
|
256
|
+
if (!isNaN(tabindex) && tabindex >= 0) {
|
|
257
|
+
return {
|
|
258
|
+
scanId: element.scanId,
|
|
259
|
+
interactionKind: "click",
|
|
260
|
+
confidence: 0.40 - disabledPenalty
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Not interactable
|
|
266
|
+
return null;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Classify all elements in a snapshot.
|
|
271
|
+
*
|
|
272
|
+
* @param {Array} elements - ScannedElement[] from Stage 1
|
|
273
|
+
* @param {Array} a11yEntries - A11yInfo[] from Stage 2
|
|
274
|
+
* @returns {{ interactables: InteractableElement[], stats: { total: number, interactable: number, byKind: Record<string, number> } }}
|
|
275
|
+
*/
|
|
276
|
+
export const classifyInteractables = (elements, a11yEntries) => {
|
|
277
|
+
// Build a11y lookup by scanId
|
|
278
|
+
const a11yMap = new Map();
|
|
279
|
+
for (const entry of a11yEntries) {
|
|
280
|
+
a11yMap.set(entry.scanId, entry);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const interactables = [];
|
|
284
|
+
const byKind = {};
|
|
285
|
+
|
|
286
|
+
for (const element of elements) {
|
|
287
|
+
const a11y = a11yMap.get(element.scanId) || null;
|
|
288
|
+
const result = classifyElement(element, a11y);
|
|
289
|
+
|
|
290
|
+
if (result && result.confidence > 0) {
|
|
291
|
+
interactables.push(result);
|
|
292
|
+
byKind[result.interactionKind] = (byKind[result.interactionKind] || 0) + 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
interactables,
|
|
298
|
+
stats: {
|
|
299
|
+
total: elements.length,
|
|
300
|
+
interactable: interactables.length,
|
|
301
|
+
byKind
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compile.js — Stage 6: Manifest Draft Compilation
|
|
3
|
+
*
|
|
4
|
+
* Runs on the CLI server. Assembles outputs of Stages 3–5 into a draft
|
|
5
|
+
* BrowserWireManifest conforming to the M0 contract-dsl schema.
|
|
6
|
+
*
|
|
7
|
+
* @typedef {import('../../src/contract-dsl/types').BrowserWireManifest} BrowserWireManifest
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const CONTRACT_VERSION = "1.0.0";
|
|
13
|
+
const MANIFEST_VERSION = "0.1.0";
|
|
14
|
+
const RECIPE_REF = "recipe://static-discovery/v1";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Standard error definitions
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const STANDARD_ERRORS = [
|
|
21
|
+
{
|
|
22
|
+
code: "ERR_TARGET_NOT_FOUND",
|
|
23
|
+
messageTemplate: "Locator matched no elements on the page",
|
|
24
|
+
classification: "recoverable"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
code: "ERR_TARGET_AMBIGUOUS",
|
|
28
|
+
messageTemplate: "Locator matched multiple elements (expected exactly one)",
|
|
29
|
+
classification: "recoverable"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
code: "ERR_TARGET_DISABLED",
|
|
33
|
+
messageTemplate: "Target element exists but is currently disabled",
|
|
34
|
+
classification: "recoverable"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: "ERR_ACTION_TIMEOUT",
|
|
38
|
+
messageTemplate: "Action did not complete within the allowed time",
|
|
39
|
+
classification: "fatal"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
code: "ERR_PRECONDITION_FAILED",
|
|
43
|
+
messageTemplate: "Action precondition not met",
|
|
44
|
+
classification: "recoverable"
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a stable manifest ID from url.
|
|
54
|
+
*/
|
|
55
|
+
const deriveManifestId = (url) => {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
const pathHash = createHash("sha256")
|
|
59
|
+
.update(parsed.pathname + parsed.search)
|
|
60
|
+
.digest("hex")
|
|
61
|
+
.slice(0, 8);
|
|
62
|
+
return `manifest_${parsed.hostname.replace(/\./g, "_")}_${pathHash}`;
|
|
63
|
+
} catch {
|
|
64
|
+
return `manifest_${createHash("sha256").update(url).digest("hex").slice(0, 12)}`;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive site from URL origin.
|
|
70
|
+
*/
|
|
71
|
+
const deriveSite = (url) => {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(url).origin;
|
|
74
|
+
} catch {
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Slugify a name for use as an ID segment.
|
|
81
|
+
*/
|
|
82
|
+
const slugify = (name) => {
|
|
83
|
+
return name
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
86
|
+
.replace(/^_+|_+$/g, "")
|
|
87
|
+
.slice(0, 40) || "unnamed";
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Map InteractionKind to a human-readable action name prefix.
|
|
92
|
+
*/
|
|
93
|
+
const interactionVerb = (kind) => {
|
|
94
|
+
const verbs = {
|
|
95
|
+
click: "Click",
|
|
96
|
+
type: "Type into",
|
|
97
|
+
select: "Select from",
|
|
98
|
+
toggle: "Toggle",
|
|
99
|
+
navigate: "Navigate to",
|
|
100
|
+
submit: "Submit",
|
|
101
|
+
scroll: "Scroll"
|
|
102
|
+
};
|
|
103
|
+
return verbs[kind] || "Interact with";
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Map InteractionKind to confidence level string.
|
|
108
|
+
*/
|
|
109
|
+
const confidenceLevel = (score) => {
|
|
110
|
+
if (score >= 0.8) return "high";
|
|
111
|
+
if (score >= 0.5) return "medium";
|
|
112
|
+
return "low";
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a LocatorSetDef from a LocatorCandidate's strategies.
|
|
117
|
+
*/
|
|
118
|
+
const buildLocatorSet = (locatorCandidate, actionId) => {
|
|
119
|
+
return {
|
|
120
|
+
id: `loc_${actionId}`,
|
|
121
|
+
strategies: locatorCandidate.strategies.map((s) => ({
|
|
122
|
+
kind: s.kind,
|
|
123
|
+
value: s.value,
|
|
124
|
+
confidence: s.confidence
|
|
125
|
+
}))
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generate action inputs for "type" and "select" interactions.
|
|
131
|
+
*/
|
|
132
|
+
const generateInputs = (interactable, element, a11yEntry) => {
|
|
133
|
+
const inputs = [];
|
|
134
|
+
|
|
135
|
+
if (interactable.interactionKind === "type") {
|
|
136
|
+
const inputType = interactable.inputType || "text";
|
|
137
|
+
const label = a11yEntry?.name || element?.attributes?.placeholder || "field";
|
|
138
|
+
|
|
139
|
+
let type = "string";
|
|
140
|
+
if (inputType === "number" || inputType === "range") {
|
|
141
|
+
type = "number";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
inputs.push({
|
|
145
|
+
name: "text",
|
|
146
|
+
type,
|
|
147
|
+
required: a11yEntry?.isRequired || false,
|
|
148
|
+
description: `Value to type into ${label}`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (interactable.interactionKind === "select") {
|
|
153
|
+
inputs.push({
|
|
154
|
+
name: "value",
|
|
155
|
+
type: "string",
|
|
156
|
+
required: true,
|
|
157
|
+
description: "Option to select"
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return inputs;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build provenance for a discovered item.
|
|
166
|
+
*/
|
|
167
|
+
const buildProvenance = (capturedAt, sessionId) => ({
|
|
168
|
+
source: "agent",
|
|
169
|
+
sessionId: sessionId || "static-discovery",
|
|
170
|
+
traceIds: [],
|
|
171
|
+
annotationIds: [],
|
|
172
|
+
capturedAt: capturedAt || new Date().toISOString()
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Main compiler
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Compile stages 3–5 output into a draft BrowserWireManifest.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} params
|
|
183
|
+
* @param {string} params.url - Page URL
|
|
184
|
+
* @param {string} params.title - Page title
|
|
185
|
+
* @param {string} params.capturedAt - ISO timestamp when the page was scanned
|
|
186
|
+
* @param {Array} params.elements - ScannedElement[]
|
|
187
|
+
* @param {Array} params.a11y - A11yInfo[]
|
|
188
|
+
* @param {Array} params.interactables - InteractableElement[]
|
|
189
|
+
* @param {Array} params.entities - EntityCandidate[]
|
|
190
|
+
* @param {Array} params.locators - LocatorCandidate[]
|
|
191
|
+
* @param {Array} [params.views] - ViewDef[] (pre-built from perception)
|
|
192
|
+
* @param {Array} [params.pages] - PageDef[] (pre-built from perception)
|
|
193
|
+
* @returns {{ manifest: BrowserWireManifest, stats: { entityCount: number, actionCount: number, errorCount: number, locatorSetCount: number } }}
|
|
194
|
+
*/
|
|
195
|
+
export const compileManifest = ({
|
|
196
|
+
url,
|
|
197
|
+
title,
|
|
198
|
+
capturedAt,
|
|
199
|
+
elements,
|
|
200
|
+
a11y,
|
|
201
|
+
interactables,
|
|
202
|
+
entities,
|
|
203
|
+
locators,
|
|
204
|
+
views,
|
|
205
|
+
pages
|
|
206
|
+
}) => {
|
|
207
|
+
const sessionId = `discovery_${Date.now()}`;
|
|
208
|
+
|
|
209
|
+
// Build lookup maps
|
|
210
|
+
const elementMap = new Map();
|
|
211
|
+
for (const el of elements) {
|
|
212
|
+
elementMap.set(el.scanId, el);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const a11yMap = new Map();
|
|
216
|
+
for (const entry of a11y) {
|
|
217
|
+
a11yMap.set(entry.scanId, entry);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const locatorMap = new Map();
|
|
221
|
+
for (const loc of locators) {
|
|
222
|
+
locatorMap.set(loc.scanId, loc);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const interactableMap = new Map();
|
|
226
|
+
for (const item of interactables) {
|
|
227
|
+
interactableMap.set(item.scanId, item);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const provenance = buildProvenance(capturedAt, sessionId);
|
|
231
|
+
|
|
232
|
+
// --- Metadata ---
|
|
233
|
+
const metadata = {
|
|
234
|
+
id: deriveManifestId(url),
|
|
235
|
+
site: deriveSite(url),
|
|
236
|
+
createdAt: capturedAt || new Date().toISOString()
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// --- Entities → EntityDef[] ---
|
|
240
|
+
const usedEntityIds = new Set();
|
|
241
|
+
const entityDefs = entities.map((candidate) => {
|
|
242
|
+
let entityId = `entity_${slugify(candidate.name)}`;
|
|
243
|
+
// Ensure uniqueness
|
|
244
|
+
if (usedEntityIds.has(entityId)) {
|
|
245
|
+
entityId = `${entityId}_${candidate.rootScanId}`;
|
|
246
|
+
}
|
|
247
|
+
usedEntityIds.add(entityId);
|
|
248
|
+
|
|
249
|
+
// Map signals to contract-dsl SignalDef format
|
|
250
|
+
const signals = candidate.signals.map((s) => ({
|
|
251
|
+
kind: s.kind,
|
|
252
|
+
value: s.value,
|
|
253
|
+
weight: Math.max(0, Math.min(1, s.weight))
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
id: entityId,
|
|
258
|
+
name: candidate.name,
|
|
259
|
+
description: `${candidate.source} entity discovered on ${title || url}`,
|
|
260
|
+
signals,
|
|
261
|
+
provenance,
|
|
262
|
+
// Store candidateId for action mapping
|
|
263
|
+
_candidateId: candidate.candidateId,
|
|
264
|
+
_memberScanIds: candidate.memberScanIds,
|
|
265
|
+
_interactableScanIds: candidate.interactableScanIds
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Build scanId → entityId reverse lookup
|
|
270
|
+
const scanIdToEntityId = new Map();
|
|
271
|
+
for (const entityDef of entityDefs) {
|
|
272
|
+
for (const sid of entityDef._interactableScanIds) {
|
|
273
|
+
// First entity wins (entities are priority-ordered)
|
|
274
|
+
if (!scanIdToEntityId.has(sid)) {
|
|
275
|
+
scanIdToEntityId.set(sid, entityDef.id);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Actions → ActionDef[] ---
|
|
281
|
+
const usedActionIds = new Set();
|
|
282
|
+
const actionDefs = [];
|
|
283
|
+
|
|
284
|
+
for (const interactable of interactables) {
|
|
285
|
+
if (interactable.interactionKind === "none") continue;
|
|
286
|
+
|
|
287
|
+
const el = elementMap.get(interactable.scanId);
|
|
288
|
+
const a11yEntry = a11yMap.get(interactable.scanId);
|
|
289
|
+
const locatorCandidate = locatorMap.get(interactable.scanId);
|
|
290
|
+
|
|
291
|
+
if (!el || !locatorCandidate || locatorCandidate.strategies.length === 0) continue;
|
|
292
|
+
|
|
293
|
+
// Find entity this belongs to
|
|
294
|
+
const entityId = scanIdToEntityId.get(interactable.scanId) || null;
|
|
295
|
+
|
|
296
|
+
// Derive action name
|
|
297
|
+
const targetName = a11yEntry?.name?.trim().slice(0, 50) || el.textContent?.trim().slice(0, 50) || el.tagName;
|
|
298
|
+
const verb = interactionVerb(interactable.interactionKind);
|
|
299
|
+
const actionName = `${verb} ${targetName}`;
|
|
300
|
+
|
|
301
|
+
let actionId = `action_${slugify(actionName)}`;
|
|
302
|
+
if (usedActionIds.has(actionId)) {
|
|
303
|
+
actionId = `${actionId}_${interactable.scanId}`;
|
|
304
|
+
}
|
|
305
|
+
usedActionIds.add(actionId);
|
|
306
|
+
|
|
307
|
+
const inputs = generateInputs(interactable, el, a11yEntry);
|
|
308
|
+
const locatorSet = buildLocatorSet(locatorCandidate, actionId);
|
|
309
|
+
|
|
310
|
+
// Collect text content for LLM context
|
|
311
|
+
const textContent = (
|
|
312
|
+
a11yEntry?.name?.trim() ||
|
|
313
|
+
el.textContent?.trim() ||
|
|
314
|
+
""
|
|
315
|
+
).slice(0, 200);
|
|
316
|
+
|
|
317
|
+
// Only include if we have an entity to attach to
|
|
318
|
+
// If no entity found, create an orphan entity
|
|
319
|
+
let resolvedEntityId = entityId;
|
|
320
|
+
if (!resolvedEntityId) {
|
|
321
|
+
const orphanId = `entity_orphan_${interactable.scanId}`;
|
|
322
|
+
entityDefs.push({
|
|
323
|
+
id: orphanId,
|
|
324
|
+
name: targetName,
|
|
325
|
+
description: `Unscoped element discovered on ${title || url}`,
|
|
326
|
+
signals: [],
|
|
327
|
+
provenance,
|
|
328
|
+
_candidateId: null,
|
|
329
|
+
_memberScanIds: [interactable.scanId],
|
|
330
|
+
_interactableScanIds: [interactable.scanId]
|
|
331
|
+
});
|
|
332
|
+
usedEntityIds.add(orphanId);
|
|
333
|
+
resolvedEntityId = orphanId;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
actionDefs.push({
|
|
337
|
+
id: actionId,
|
|
338
|
+
entityId: resolvedEntityId,
|
|
339
|
+
name: actionName,
|
|
340
|
+
interactionKind: interactable.interactionKind,
|
|
341
|
+
textContent: textContent || undefined,
|
|
342
|
+
inputs,
|
|
343
|
+
preconditions: [
|
|
344
|
+
{ id: "pre_visible", description: "Target element is visible on the page" }
|
|
345
|
+
],
|
|
346
|
+
postconditions: [
|
|
347
|
+
{ id: "post_exists", description: "Action completed without error" }
|
|
348
|
+
],
|
|
349
|
+
recipeRef: RECIPE_REF,
|
|
350
|
+
locatorSet,
|
|
351
|
+
errors: ["ERR_TARGET_NOT_FOUND", "ERR_TARGET_AMBIGUOUS"],
|
|
352
|
+
confidence: {
|
|
353
|
+
score: interactable.confidence,
|
|
354
|
+
level: confidenceLevel(interactable.confidence)
|
|
355
|
+
},
|
|
356
|
+
provenance
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Clean internal fields from entity defs
|
|
361
|
+
const cleanEntityDefs = entityDefs.map(({ _candidateId, _memberScanIds, _interactableScanIds, ...rest }) => rest);
|
|
362
|
+
|
|
363
|
+
// --- Assemble manifest ---
|
|
364
|
+
const manifest = {
|
|
365
|
+
contractVersion: CONTRACT_VERSION,
|
|
366
|
+
manifestVersion: MANIFEST_VERSION,
|
|
367
|
+
metadata,
|
|
368
|
+
entities: cleanEntityDefs,
|
|
369
|
+
actions: actionDefs,
|
|
370
|
+
errors: STANDARD_ERRORS
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Attach views and pages if provided
|
|
374
|
+
if (views && views.length > 0) {
|
|
375
|
+
manifest.views = views;
|
|
376
|
+
}
|
|
377
|
+
if (pages && pages.length > 0) {
|
|
378
|
+
manifest.pages = pages;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
manifest,
|
|
383
|
+
stats: {
|
|
384
|
+
entityCount: cleanEntityDefs.length,
|
|
385
|
+
actionCount: actionDefs.length,
|
|
386
|
+
errorCount: STANDARD_ERRORS.length,
|
|
387
|
+
locatorSetCount: actionDefs.length,
|
|
388
|
+
viewCount: views?.length || 0,
|
|
389
|
+
pageCount: pages?.length || 0
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
};
|