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,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
+ };