@zibby/core 0.1.47 → 0.2.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.
Files changed (41) hide show
  1. package/dist/index.js +100 -100
  2. package/dist/package.json +2 -2
  3. package/dist/register-built-in-strategies.js +52 -52
  4. package/dist/strategies/assistant-strategy.js +1 -1
  5. package/dist/strategies/claude-strategy.js +3 -3
  6. package/dist/strategies/codex-strategy.js +3 -3
  7. package/dist/strategies/cursor-strategy.js +30 -30
  8. package/dist/strategies/gemini-strategy.js +13 -13
  9. package/dist/strategies/index.js +57 -57
  10. package/dist/templates/browser-test-automation/README.md +136 -0
  11. package/dist/templates/browser-test-automation/chat.mjs +36 -0
  12. package/dist/templates/browser-test-automation/graph.mjs +54 -0
  13. package/dist/templates/browser-test-automation/nodes/execute-live.mjs +222 -0
  14. package/dist/templates/browser-test-automation/nodes/generate-script.mjs +97 -0
  15. package/dist/templates/browser-test-automation/nodes/index.mjs +3 -0
  16. package/dist/templates/browser-test-automation/nodes/preflight.mjs +59 -0
  17. package/dist/templates/browser-test-automation/nodes/utils.mjs +297 -0
  18. package/dist/templates/browser-test-automation/pipeline-ids.js +12 -0
  19. package/dist/templates/browser-test-automation/result-handler.mjs +327 -0
  20. package/dist/templates/browser-test-automation/run-index.mjs +418 -0
  21. package/dist/templates/browser-test-automation/run_test.json +358 -0
  22. package/dist/templates/code-analysis/graph.js +72 -0
  23. package/dist/templates/code-analysis/index.js +18 -0
  24. package/dist/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
  25. package/dist/templates/code-analysis/nodes/create-pr-node.js +175 -0
  26. package/dist/templates/code-analysis/nodes/finalize-node.js +118 -0
  27. package/dist/templates/code-analysis/nodes/generate-code-node.js +425 -0
  28. package/dist/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
  29. package/dist/templates/code-analysis/nodes/services/prMetaService.js +86 -0
  30. package/dist/templates/code-analysis/nodes/setup-node.js +142 -0
  31. package/dist/templates/code-analysis/prompts/analyze-ticket.md +181 -0
  32. package/dist/templates/code-analysis/prompts/generate-code.md +33 -0
  33. package/dist/templates/code-analysis/prompts/generate-test-cases.md +110 -0
  34. package/dist/templates/code-analysis/state.js +40 -0
  35. package/dist/templates/code-implementation/graph.js +35 -0
  36. package/dist/templates/code-implementation/index.js +7 -0
  37. package/dist/templates/code-implementation/state.js +14 -0
  38. package/dist/templates/global-setup.js +56 -0
  39. package/dist/templates/index.js +94 -0
  40. package/dist/templates/register-nodes.js +24 -0
  41. package/package.json +2 -2
@@ -0,0 +1,297 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+
3
+ const ACTIONABLE_EVENTS = ['navigate', 'type', 'fill', 'click', 'select_option', 'select'];
4
+
5
+ // ARIA roles that accept text input via Playwright .fill()
6
+ const FILLABLE_ROLES = new Set(['textbox', 'combobox', 'spinbutton', 'searchbox']);
7
+
8
+ /**
9
+ * Load recorded browser events from a session's events.json,
10
+ * filtering to only actionable events (navigate, fill, click, etc.)
11
+ */
12
+ export function loadRecordedActions(sessionPath, nodeName = 'execute_live') {
13
+ const eventsPath = `${sessionPath}/${nodeName}/events.json`;
14
+ if (!existsSync(eventsPath)) return [];
15
+ try {
16
+ return JSON.parse(readFileSync(eventsPath, 'utf-8'))
17
+ .filter(e => ACTIONABLE_EVENTS.includes(e.type))
18
+ .map(e => ({
19
+ type: e.type,
20
+ stableId: e.stableId || e.data?.stableId,
21
+ value: e.data?.text || e.data?.params?.text || e.data?.url || e.data?.params?.url || e.data?.values?.[0] || e.data?.params?.values?.[0],
22
+ element: e.data?.element || e.data?.params?.element,
23
+ reasoning: e.reasoning || e.description
24
+ }));
25
+ } catch (e) {
26
+ console.error('Failed to read events:', e.message);
27
+ return [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Index AI-captured selectors by element description (lowercase)
33
+ * for fuzzy matching when building fallback selectors.
34
+ */
35
+ export function buildSelectorMap(actions) {
36
+ const map = {};
37
+ for (const a of actions || []) {
38
+ if (a.selectors?.role) {
39
+ map[(a.description || '').toLowerCase()] = a.selectors;
40
+ }
41
+ }
42
+ return map;
43
+ }
44
+
45
+ /** Extract meaningful tokens from a description string for fuzzy matching. */
46
+ function extractTokens(str) {
47
+ return str.replace(/[-–—]/g, ' ').split(/\s+/).filter(t => t.length > 1);
48
+ }
49
+
50
+ /** Score how well two descriptions match by counting shared tokens. */
51
+ function tokenOverlapScore(a, b) {
52
+ const tokensA = extractTokens(a.toLowerCase());
53
+ const tokensB = extractTokens(b.toLowerCase());
54
+ return tokensA.filter(t => tokensB.some(tb => tb.includes(t) || t.includes(tb))).length;
55
+ }
56
+
57
+ /**
58
+ * Check whether an ARIA role is compatible with an action type.
59
+ * Prevents click actions from getting textbox fallbacks and vice versa.
60
+ */
61
+ function isRoleCompatible(role, actionType) {
62
+ if (!role || role === 'generic' || role === 'none') return true;
63
+ if (actionType === 'click' && FILLABLE_ROLES.has(role)) return false;
64
+ if ((actionType === 'fill' || actionType === 'type') && !FILLABLE_ROLES.has(role)) return false;
65
+ return true;
66
+ }
67
+
68
+ function formatRoleSelector(roleObj) {
69
+ if (!roleObj) return '';
70
+ const { role, name } = roleObj;
71
+ if (role === 'generic' || role === 'none') {
72
+ return name ? ` | fallback: getByText('${name}')` : '';
73
+ }
74
+ return ` | fallback: getByRole('${role}'${name ? `, { name: '${name}' }` : ''})`;
75
+ }
76
+
77
+ /**
78
+ * Find a fallback selector for an element by matching against the AI selector map.
79
+ * 1. Try exact description match first.
80
+ * 2. Try role.name containment — prefer the longest (most specific) role name found in the element description.
81
+ * 3. Fall back to fuzzy token matching with ≥2 token overlap threshold.
82
+ * 4. Validate ARIA role compatibility with the action type.
83
+ */
84
+ function findFallbackSelector(elementKey, selectorMap, actionType) {
85
+ if (selectorMap[elementKey]?.role && isRoleCompatible(selectorMap[elementKey].role.role, actionType)) {
86
+ return formatRoleSelector(selectorMap[elementKey].role);
87
+ }
88
+
89
+ let bestNameMatch = null;
90
+ let bestNameLen = 0;
91
+ for (const [, selectors] of Object.entries(selectorMap)) {
92
+ if (!isRoleCompatible(selectors.role?.role, actionType)) continue;
93
+ const roleName = selectors.role?.name;
94
+ if (!roleName) continue;
95
+ if (elementKey.includes(roleName.toLowerCase()) && roleName.length > bestNameLen) {
96
+ bestNameLen = roleName.length;
97
+ bestNameMatch = selectors;
98
+ }
99
+ }
100
+ if (bestNameMatch?.role) return formatRoleSelector(bestNameMatch.role);
101
+
102
+ let bestMatch = null;
103
+ let bestScore = 0;
104
+ for (const [k, selectors] of Object.entries(selectorMap)) {
105
+ if (!isRoleCompatible(selectors.role?.role, actionType)) continue;
106
+ const score = tokenOverlapScore(elementKey, k);
107
+ if (score > bestScore) {
108
+ bestScore = score;
109
+ bestMatch = selectors;
110
+ }
111
+ }
112
+ if (!bestMatch?.role || bestScore < 2) return '';
113
+ return formatRoleSelector(bestMatch.role);
114
+ }
115
+
116
+ /** Extract visible text hint from element description for use as a getByText fallback. */
117
+ function extractTextHint(elementDesc) {
118
+ if (!elementDesc) return '';
119
+ const cnMatch = elementDesc.match(/[\u4e00-\u9fff\u3000-\u303f]+/g);
120
+ if (cnMatch) return cnMatch.join('');
121
+ const afterDash = elementDesc.split(/[-–—]\s*/).pop()?.trim();
122
+ if (afterDash && afterDash !== elementDesc) return afterDash;
123
+ return '';
124
+ }
125
+
126
+ /**
127
+ * Mark duplicate stableIds across actions. When the same stableId appears
128
+ * on different steps, the second+ occurrence gets nullified with a marker
129
+ * so the LLM knows to use the fallback selector instead.
130
+ */
131
+ export function deduplicateStableIds(actions) {
132
+ const seen = new Map();
133
+ return actions.map((a, i) => {
134
+ if (!a.stableId || a.stableId === 'undefined') return a;
135
+ if (seen.has(a.stableId)) {
136
+ return { ...a, _dupOf: seen.get(a.stableId), stableId: null };
137
+ }
138
+ seen.set(a.stableId, i);
139
+ return a;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Detect a form-submission setup sequence at the start of recorded actions.
145
+ * Purely structural — no hardcoded keywords. Finds a block of fill/type/click
146
+ * actions near the start containing ≥2 fills, ending with a click (submit).
147
+ * The LLM decides what it is (login, registration, etc.) from context.
148
+ */
149
+ export function detectLoginPattern(actions) {
150
+ if (!actions || actions.length < 3) return null;
151
+
152
+ let start = 0;
153
+ while (start < actions.length && actions[start].type === 'navigate') start++;
154
+ if (start >= actions.length) return null;
155
+
156
+ let lastFillIdx = -1;
157
+ let fillCount = 0;
158
+ let i = start;
159
+ while (i < actions.length) {
160
+ const t = actions[i].type;
161
+ if (t === 'fill' || t === 'type') {
162
+ fillCount++;
163
+ lastFillIdx = i;
164
+ } else if (t !== 'click') {
165
+ break;
166
+ }
167
+ i++;
168
+ }
169
+
170
+ if (fillCount < 2 || lastFillIdx < 0) return null;
171
+
172
+ let submitIdx = -1;
173
+ for (let j = lastFillIdx + 1; j < actions.length; j++) {
174
+ if (actions[j].type === 'click') { submitIdx = j; break; }
175
+ if (actions[j].type !== 'fill' && actions[j].type !== 'type') break;
176
+ }
177
+ if (submitIdx < 0) return null;
178
+
179
+ const remaining = actions.length - submitIdx - 1;
180
+ if (remaining < 1) return null;
181
+
182
+ return {
183
+ startIndex: 0,
184
+ endIndex: submitIdx,
185
+ fillCount,
186
+ actions: actions.slice(0, submitIdx + 1),
187
+ };
188
+ }
189
+
190
+ /** Format a single recorded event into a human-readable action line with stableId and fallback selector. */
191
+ export function formatAction(event, index, selectorMap) {
192
+ const num = index + 1;
193
+ const reason = event.reasoning ? ` | reason: "${event.reasoning}"` : '';
194
+ const hasStableId = event.stableId && event.stableId !== 'undefined';
195
+
196
+ if (event._dupOf !== undefined) {
197
+ const dupHint = `[DUPLICATE_STABLE_ID — same as step ${event._dupOf + 1}, use fallback]`;
198
+ const fallback = findFallbackSelector((event.element || '').toLowerCase(), selectorMap, event.type);
199
+ const textFb = !fallback ? (() => { const h = extractTextHint(event.element); return h ? ` | fallback: getByText('${h}')` : ''; })() : '';
200
+ switch (event.type) {
201
+ case 'click':
202
+ return `${num}. CLICK ${dupHint} - ${event.element || 'element'}${fallback || textFb}${reason}`;
203
+ case 'fill': case 'type':
204
+ return `${num}. FILL ${dupHint} with "${event.value}" - ${event.element || 'field'}${fallback || textFb}${reason}`;
205
+ default:
206
+ return `${num}. ${event.type} ${dupHint} ${event.value || ''}${fallback || textFb}${reason}`;
207
+ }
208
+ }
209
+
210
+ const fallback = findFallbackSelector((event.element || '').toLowerCase(), selectorMap, event.type);
211
+ const idPart = hasStableId ? `[stableId="${event.stableId}"]` : '[NO_STABLE_ID]';
212
+
213
+ let textFallback = '';
214
+ if (!hasStableId && !fallback) {
215
+ const hint = extractTextHint(event.element);
216
+ if (hint) textFallback = ` | fallback: getByText('${hint}')`;
217
+ }
218
+
219
+ switch (event.type) {
220
+ case 'navigate':
221
+ return `${num}. NAVIGATE to: ${event.value}${reason}`;
222
+ case 'click':
223
+ return `${num}. CLICK ${idPart} - ${event.element || 'element'}${fallback || textFallback}${reason}`;
224
+ case 'fill': case 'type':
225
+ return `${num}. FILL ${idPart} with "${event.value}" - ${event.element || 'field'}${fallback || textFallback}${reason}`;
226
+ case 'select': case 'select_option':
227
+ return `${num}. SELECT ${idPart} option "${event.value}" - ${event.element || 'dropdown'}${fallback || textFallback}${reason}`;
228
+ default:
229
+ return `${num}. ${event.type} ${hasStableId ? idPart : ''} ${event.value || ''}${fallback || textFallback}${reason}`;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Build the full RECORDED ACTIONS prompt block by loading events.json
235
+ * and merging with AI-captured selectors from execution output.
236
+ * Auto-deduplicates stableIds before formatting.
237
+ */
238
+ export function formatRecordedActions(sessionPath, executionActions) {
239
+ let recorded = loadRecordedActions(sessionPath);
240
+ if (recorded.length === 0) return '';
241
+ recorded = deduplicateStableIds(recorded);
242
+ const selectorMap = buildSelectorMap(executionActions);
243
+ return `RECORDED ACTIONS:\n${recorded.map((e, i) => formatAction(e, i, selectorMap)).join('\n')}\n\nGenerate a CLEAN script from these — not a 1:1 replay. If stableId fails, use the fallback selector.`;
244
+ }
245
+
246
+ /**
247
+ * Format a prompt hint when a setup sequence (form submission) is detected
248
+ * at the start of the recorded actions. Tells the LLM to check for or
249
+ * create a shared fixture — no prescriptive file names or code patterns.
250
+ */
251
+ export function formatSetupHint(setup) {
252
+ if (!setup) return '';
253
+ return `
254
+ SETUP SEQUENCE DETECTED (steps ${setup.startIndex + 1}–${setup.endIndex + 1}, ${setup.fillCount} form fields):
255
+ The first ${setup.endIndex + 1} actions are a form-submission sequence that will repeat across tests.
256
+ BEFORE writing ANY new files, you MUST search the codebase:
257
+ 1. Search tests/ for existing fixture, helper, or setup files (e.g. grep for "export.*function" in tests/)
258
+ 2. Read any found files to see what they export
259
+ 3. If an existing function already handles this setup → import and reuse it, skip steps ${setup.startIndex + 1}–${setup.endIndex + 1}
260
+ 4. ONLY create a new fixture if nothing existing covers these steps
261
+ DO NOT create a duplicate fixture. Reuse what exists.
262
+ `;
263
+ }
264
+
265
+ /** Format preflight assertions into a simple numbered checklist for the execute_live prompt. */
266
+ export function formatAssertionChecklist(assertions) {
267
+ if (!assertions?.length) return '';
268
+ return assertions.map((a, i) =>
269
+ `${i + 1}. ${a.description} → expected: ${a.expected}`
270
+ ).join('\n');
271
+ }
272
+
273
+ /**
274
+ * Merge preflight assertion definitions with execution results (pass/fail/evidence)
275
+ * into an ASSERTIONS TO IMPLEMENT block for the generate_script prompt.
276
+ */
277
+ export function formatAssertionsWithResults(preflightAssertions, executionAssertions, notes, finalUrl) {
278
+ if (!preflightAssertions?.length && !executionAssertions?.length) return '';
279
+
280
+ const lines = preflightAssertions?.length > 0
281
+ ? preflightAssertions.map((a, i) => {
282
+ const exec = executionAssertions?.[i];
283
+ const status = exec ? (exec.passed ? '✅ PASSED' : '❌ FAILED') : '⚠️ NOT CHECKED';
284
+ const evidence = exec?.evidence ? ` | evidence: "${exec.evidence}"` : '';
285
+ return `${i + 1}. [${status}] ${a.description} → expected: ${a.expected}${evidence}`;
286
+ })
287
+ : executionAssertions.map((a, i) =>
288
+ typeof a === 'object'
289
+ ? `${i + 1}. ${a.description || a.type}: expected "${a.expected}", actual "${a.actual}"`
290
+ : `${i + 1}. ${a}`
291
+ );
292
+
293
+ return `ASSERTIONS TO IMPLEMENT:
294
+ ${lines.join('\n')}
295
+
296
+ Final URL: ${finalUrl || 'unknown'}${notes ? `\nAI OBSERVATION: ${notes}` : ''}`;
297
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Browser-test **template only** — ordered graph node ids used for:
3
+ * - JSONL `recordKind: 'progress'` validation in this template’s `run-index.mjs`
4
+ * - Studio UI that visualizes this workflow (imports from this path, not from `src/`).
5
+ *
6
+ * Other workflows should define their own ordered id list alongside their template.
7
+ */
8
+ export const BROWSER_TEST_PIPELINE_NODE_IDS = Object.freeze([
9
+ 'preflight',
10
+ 'execute_live',
11
+ 'generate_script',
12
+ ]);
@@ -0,0 +1,327 @@
1
+ /**
2
+ * BrowserTestResultHandler - Browser test automation post-processing.
3
+ * Extends core ResultHandler with event enrichment, video handling,
4
+ * and assertion → event ID resolution.
5
+ */
6
+
7
+ import {
8
+ writeFileSync,
9
+ existsSync,
10
+ readFileSync,
11
+ statSync,
12
+ copyFileSync,
13
+ mkdirSync,
14
+ } from 'fs';
15
+ import { join, isAbsolute, normalize } from 'path';
16
+ import { spawnSync } from 'child_process';
17
+ import { ResultHandler, logger } from '@zibby/core';
18
+
19
+ export class BrowserTestResultHandler extends ResultHandler {
20
+
21
+ static async onNodeSaved(nodeFolder, executionData) {
22
+ this.enrichEvents(nodeFolder, executionData);
23
+ const videoPath = join(nodeFolder, 'recording.webm');
24
+ if (existsSync(videoPath)) {
25
+ this.recalculateEventTimestamps(nodeFolder, videoPath);
26
+ }
27
+ }
28
+
29
+ // ── Video ──────────────────────────────────────────────────
30
+
31
+ static getVideoDurationMs(videoPath) {
32
+ try {
33
+ const { stdout: result } = spawnSync('ffprobe', [
34
+ '-v', 'error', '-show_entries', 'format=duration',
35
+ '-of', 'default=noprint_wrappers=1:nokey=1', videoPath
36
+ ], { encoding: 'utf-8', timeout: 5000 });
37
+ const durationSec = parseFloat(result.trim());
38
+ if (!isNaN(durationSec)) {
39
+ const durationMs = Math.round(durationSec * 1000);
40
+ logger.debug(`Video duration (ffprobe): ${durationMs}ms`);
41
+ return durationMs;
42
+ }
43
+ } catch (_e) { /* ffprobe not available */ }
44
+
45
+ try {
46
+ const stats = statSync(videoPath);
47
+ const durationMs = Math.floor(stats.mtimeMs - stats.birthtimeMs);
48
+ if (durationMs > 0 && durationMs < 600000) {
49
+ logger.debug(`Video duration (file stats): ${durationMs}ms`);
50
+ return durationMs;
51
+ }
52
+ } catch (e) {
53
+ console.warn(`⚠️ Could not determine video duration: ${e.message}`);
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ static recalculateEventTimestamps(folder, videoPath) {
60
+ try {
61
+ const eventsPath = join(folder, 'events.json');
62
+ if (!existsSync(eventsPath)) return;
63
+
64
+ const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
65
+ if (events.length === 0) return;
66
+
67
+ const videoDurationMs = this.getVideoDurationMs(videoPath);
68
+ if (!videoDurationMs) {
69
+ console.warn('⚠️ Could not get video duration');
70
+ return;
71
+ }
72
+
73
+ const closeEvent = events.find(e => e.type === 'close');
74
+ if (!closeEvent) {
75
+ console.warn('⚠️ No close event found');
76
+ return;
77
+ }
78
+
79
+ const closeMs = new Date(closeEvent.timestamp).getTime();
80
+ const videoStartMs = closeMs - videoDurationMs;
81
+ let realVideoStartMs = videoStartMs;
82
+
83
+ try {
84
+ const videoStats = statSync(videoPath);
85
+ const fileBirthMs = videoStats.birthtimeMs;
86
+
87
+ logger.info('Video file timing:');
88
+ logger.info(` File created: ${new Date(fileBirthMs).toISOString()}`);
89
+ logger.info(` Close event: ${new Date(closeMs).toISOString()}`);
90
+ logger.info(` Duration: ${videoDurationMs}ms`);
91
+ logger.info(` Calculated start: ${new Date(videoStartMs).toISOString()}`);
92
+
93
+ const birthToClose = closeMs - fileBirthMs;
94
+ if (birthToClose > 0 && birthToClose <= videoDurationMs + 5000) {
95
+ realVideoStartMs = fileBirthMs;
96
+ } else {
97
+ logger.debug('File birth unreliable, using calculated start');
98
+ }
99
+ } catch (e) {
100
+ logger.debug(`Using calculated video start: ${new Date(videoStartMs).toISOString()}`);
101
+ logger.debug(`Error: ${e.message}`);
102
+ }
103
+
104
+ events.forEach(event => {
105
+ const eventMs = new Date(event.timestamp).getTime();
106
+ const offsetMs = eventMs - realVideoStartMs;
107
+ event.videoOffsetMs = Math.max(0, Math.round(offsetMs));
108
+ event.videoOffsetFormatted = this._formatTime(event.videoOffsetMs);
109
+ });
110
+
111
+ writeFileSync(eventsPath, JSON.stringify(events, null, 2));
112
+ logger.info(`Recalculated ${events.length} events`);
113
+ } catch (err) {
114
+ console.warn(`[INFO] Could not recalculate timestamps: ${err.message}`);
115
+ }
116
+ }
117
+
118
+ static _formatTime(ms) {
119
+ const totalMs = Math.round(ms);
120
+ const totalSeconds = Math.floor(totalMs / 1000);
121
+ const hours = Math.floor(totalSeconds / 3600);
122
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
123
+ const seconds = totalSeconds % 60;
124
+ const milliseconds = totalMs % 1000;
125
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
126
+ }
127
+
128
+ // ── Event enrichment + assertion resolution ────────────────
129
+
130
+ static enrichEvents(executeLiveFolder, executionData) {
131
+ try {
132
+ const eventsPath = join(executeLiveFolder, 'events.json');
133
+ if (!existsSync(eventsPath)) return;
134
+
135
+ let events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
136
+ const aiActions = executionData.actions || [];
137
+ const aiSteps = executionData.steps || [];
138
+
139
+ logger.info(`Event Enrichment: ${events.length} recorded events, ${aiActions.length} AI actions`);
140
+ logger.info(`Recorded event types: ${events.map(e => e.type).join(', ')}`);
141
+ logger.info(`AI action types: ${aiActions.map(a => a.type).join(', ')}`);
142
+
143
+ const usedActionIndices = new Set();
144
+
145
+ events = events.map((event) => {
146
+ const actionableTypes = ['navigate', 'click', 'fill', 'type', 'select', 'keypress', 'hover', 'drag'];
147
+
148
+ if (actionableTypes.includes(event.type)) {
149
+ let bestMatch = null;
150
+ let bestScore = 0;
151
+
152
+ aiActions.forEach((action, actionIndex) => {
153
+ if (usedActionIndices.has(actionIndex)) return;
154
+
155
+ let score = 0;
156
+ const typeMatches =
157
+ action.type === event.type ||
158
+ (action.type === 'fill' && (event.type === 'fill' || event.type === 'type')) ||
159
+ (action.type === 'type' && (event.type === 'fill' || event.type === 'type'));
160
+
161
+ if (typeMatches) {
162
+ score += 10;
163
+ if (actionIndex === usedActionIndices.size) score += 5;
164
+
165
+ if (event.data?.params?.element && action.description) {
166
+ const elementStr = String(event.data.params.element);
167
+ if (action.description.toLowerCase().includes(elementStr.toLowerCase().slice(0, 10))) {
168
+ score += 3;
169
+ }
170
+ }
171
+
172
+ if (score > bestScore) {
173
+ bestScore = score;
174
+ bestMatch = { action, actionIndex };
175
+ }
176
+ }
177
+ });
178
+
179
+ if (bestMatch) {
180
+ event.description = bestMatch.action.description;
181
+ event.reasoning = bestMatch.action.reasoning;
182
+ event.matchedActionIndex = bestMatch.actionIndex;
183
+ usedActionIndices.add(bestMatch.actionIndex);
184
+
185
+ if (aiSteps.length > 0) {
186
+ const matchingStep = aiSteps.find(s => {
187
+ if (typeof s !== 'string') return false;
188
+ const sLower = s.toLowerCase();
189
+ return sLower.includes(event.type) ||
190
+ (event.data?.params?.element && sLower.includes(String(event.data.params.element).toLowerCase()));
191
+ });
192
+ if (matchingStep) event.step = matchingStep;
193
+ }
194
+ } else {
195
+ const toolName = event.data?.tool || 'action';
196
+ event.description = `${toolName} action`;
197
+ event.reasoning = 'Browser interaction';
198
+ }
199
+ } else if (event.type === 'screenshot') {
200
+ const evidenceScreenshot = executionData.evidenceScreenshot;
201
+ if (evidenceScreenshot) {
202
+ event.description = evidenceScreenshot.description;
203
+ event.verdict = evidenceScreenshot.verdict;
204
+ event.isEvidence = true;
205
+ event.reasoning = `Evidence of test ${evidenceScreenshot.verdict === 'pass' ? 'passing' : 'failing'}`;
206
+ logger.debug(`Enriched evidence screenshot: ${evidenceScreenshot.verdict.toUpperCase()}`);
207
+ }
208
+ }
209
+
210
+ return event;
211
+ });
212
+
213
+ logger.info(`Matching results: ${usedActionIndices.size}/${aiActions.length} AI actions used`);
214
+
215
+ writeFileSync(eventsPath, JSON.stringify(events, null, 2), 'utf-8');
216
+ logger.info(`Enriched ${events.length} events with AI descriptions and reasoning`);
217
+
218
+ // Resolve assertion verifiedAfterAction (action index) -> verifiedAtEventId (event id)
219
+ const assertions = executionData.assertions || [];
220
+ if (assertions.length > 0) {
221
+ const actionToEvent = new Map();
222
+ for (const event of events) {
223
+ if (event.matchedActionIndex !== undefined) {
224
+ actionToEvent.set(event.matchedActionIndex, event.id);
225
+ }
226
+ }
227
+
228
+ for (const assertion of assertions) {
229
+ if (assertion.verifiedAfterAction === undefined) continue;
230
+
231
+ const exactEvent = actionToEvent.get(assertion.verifiedAfterAction);
232
+ if (exactEvent !== undefined) {
233
+ assertion.verifiedAtEventId = exactEvent;
234
+ continue;
235
+ }
236
+
237
+ let bestActionIdx = -1;
238
+ for (const [actionIdx] of actionToEvent) {
239
+ if (actionIdx <= assertion.verifiedAfterAction && actionIdx > bestActionIdx) {
240
+ bestActionIdx = actionIdx;
241
+ }
242
+ }
243
+ if (bestActionIdx >= 0) {
244
+ assertion.verifiedAtEventId = actionToEvent.get(bestActionIdx);
245
+ }
246
+ }
247
+
248
+ const resultPath = join(executeLiveFolder, 'result.json');
249
+ if (existsSync(resultPath)) {
250
+ const resultData = JSON.parse(readFileSync(resultPath, 'utf-8'));
251
+ resultData.assertions = assertions;
252
+ writeFileSync(resultPath, JSON.stringify(resultData, null, 2), 'utf-8');
253
+ logger.info(`Resolved verifiedAtEventId for ${assertions.filter(a => a.verifiedAtEventId !== undefined).length}/${assertions.length} assertions`);
254
+ }
255
+ }
256
+ } catch (err) {
257
+ console.warn('⚠️ Could not enrich events:', err.message);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Copy the final Playwright/Selenium file into `session/generate_script/` under stable names
263
+ * so Studio (Electron + web bridge) can load scripts without resolving arbitrary `scriptPath`.
264
+ * Safe to run even when the agent already wrote into that folder.
265
+ */
266
+ static ensureStudioCodegenMirror(sessionRoot, cwd) {
267
+ if (!sessionRoot || typeof sessionRoot !== 'string') return;
268
+ const gsDir = join(sessionRoot, 'generate_script');
269
+ const resultPath = join(gsDir, 'result.json');
270
+ if (!existsSync(resultPath)) return;
271
+
272
+ let data;
273
+ try {
274
+ data = JSON.parse(readFileSync(resultPath, 'utf-8'));
275
+ } catch {
276
+ return;
277
+ }
278
+ const raw = data?.scriptPath;
279
+ if (!raw || typeof raw !== 'string' || !raw.trim()) return;
280
+
281
+ const t = raw.trim();
282
+ const candidates = [];
283
+ if (isAbsolute(t)) candidates.push(normalize(t));
284
+ if (cwd && typeof cwd === 'string') candidates.push(join(cwd, t));
285
+ candidates.push(join(sessionRoot, t));
286
+ candidates.push(join(gsDir, t));
287
+
288
+ let src = null;
289
+ for (const c of candidates) {
290
+ try {
291
+ if (existsSync(c) && statSync(c).isFile()) {
292
+ src = c;
293
+ break;
294
+ }
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ }
299
+ if (!src) {
300
+ logger.debug(`[Studio] Codegen mirror: could not resolve scriptPath "${t}"`);
301
+ return;
302
+ }
303
+
304
+ const lower = src.toLowerCase();
305
+ const destName = lower.endsWith('.py')
306
+ ? 'test.selenium.py'
307
+ : lower.endsWith('.ts') || lower.endsWith('.tsx')
308
+ ? 'playwright.spec.ts'
309
+ : 'generated-test.spec.js';
310
+ const dest = join(gsDir, destName);
311
+
312
+ try {
313
+ if (existsSync(dest)) {
314
+ try {
315
+ if (statSync(dest).mtimeMs >= statSync(src).mtimeMs) return;
316
+ } catch {
317
+ /* replace if unsure */
318
+ }
319
+ }
320
+ mkdirSync(gsDir, { recursive: true });
321
+ copyFileSync(src, dest);
322
+ logger.info(`[Studio] Mirrored script for discovery: ${dest}`);
323
+ } catch (e) {
324
+ logger.debug(`[Studio] Codegen mirror failed: ${e.message}`);
325
+ }
326
+ }
327
+ }