@zibby/core 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 +147 -0
- package/package.json +94 -0
- package/src/agents/base.js +361 -0
- package/src/constants.js +47 -0
- package/src/enrichment/base.js +49 -0
- package/src/enrichment/enrichers/accessibility-enricher.js +197 -0
- package/src/enrichment/enrichers/dom-enricher.js +171 -0
- package/src/enrichment/enrichers/page-state-enricher.js +129 -0
- package/src/enrichment/enrichers/position-enricher.js +67 -0
- package/src/enrichment/index.js +96 -0
- package/src/enrichment/mcp-integration.js +149 -0
- package/src/enrichment/mcp-ref-enricher.js +78 -0
- package/src/enrichment/pipeline.js +192 -0
- package/src/enrichment/trace-text-enricher.js +115 -0
- package/src/framework/AGENTS.md +98 -0
- package/src/framework/agents/base.js +72 -0
- package/src/framework/agents/claude-strategy.js +278 -0
- package/src/framework/agents/cursor-strategy.js +459 -0
- package/src/framework/agents/index.js +105 -0
- package/src/framework/agents/utils/cursor-output-formatter.js +67 -0
- package/src/framework/agents/utils/openai-proxy-formatter.js +249 -0
- package/src/framework/code-generator.js +301 -0
- package/src/framework/constants.js +33 -0
- package/src/framework/context-loader.js +101 -0
- package/src/framework/function-bridge.js +78 -0
- package/src/framework/function-skill-registry.js +20 -0
- package/src/framework/graph-compiler.js +342 -0
- package/src/framework/graph.js +610 -0
- package/src/framework/index.js +28 -0
- package/src/framework/node-registry.js +163 -0
- package/src/framework/node.js +259 -0
- package/src/framework/output-parser.js +71 -0
- package/src/framework/skill-registry.js +55 -0
- package/src/framework/state-utils.js +52 -0
- package/src/framework/state.js +67 -0
- package/src/framework/tool-resolver.js +65 -0
- package/src/index.js +342 -0
- package/src/runtime/generation/base.js +46 -0
- package/src/runtime/generation/index.js +70 -0
- package/src/runtime/generation/mcp-ref-strategy.js +197 -0
- package/src/runtime/generation/stable-id-strategy.js +170 -0
- package/src/runtime/stable-id-runtime.js +248 -0
- package/src/runtime/verification/base.js +44 -0
- package/src/runtime/verification/index.js +67 -0
- package/src/runtime/verification/playwright-json-strategy.js +119 -0
- package/src/runtime/zibby-runtime.js +299 -0
- package/src/sync/index.js +2 -0
- package/src/sync/uploader.js +29 -0
- package/src/tools/run-playwright-test.js +158 -0
- package/src/utils/adf-converter.js +68 -0
- package/src/utils/ast-utils.js +37 -0
- package/src/utils/ci-setup.js +124 -0
- package/src/utils/cursor-utils.js +71 -0
- package/src/utils/logger.js +144 -0
- package/src/utils/mcp-config-writer.js +115 -0
- package/src/utils/node-schema-parser.js +522 -0
- package/src/utils/post-process-events.js +55 -0
- package/src/utils/result-handler.js +102 -0
- package/src/utils/ripple-effect.js +84 -0
- package/src/utils/selector-generator.js +239 -0
- package/src/utils/streaming-parser.js +387 -0
- package/src/utils/test-post-processor.js +211 -0
- package/src/utils/timeline.js +217 -0
- package/src/utils/trace-parser.js +325 -0
- package/src/utils/video-organizer.js +91 -0
- package/templates/browser-test-automation/README.md +114 -0
- package/templates/browser-test-automation/graph.js +54 -0
- package/templates/browser-test-automation/nodes/execute-live.js +250 -0
- package/templates/browser-test-automation/nodes/generate-script.js +77 -0
- package/templates/browser-test-automation/nodes/index.js +3 -0
- package/templates/browser-test-automation/nodes/preflight.js +59 -0
- package/templates/browser-test-automation/nodes/utils.js +154 -0
- package/templates/browser-test-automation/result-handler.js +286 -0
- package/templates/code-analysis/graph.js +72 -0
- package/templates/code-analysis/index.js +18 -0
- package/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
- package/templates/code-analysis/nodes/create-pr-node.js +175 -0
- package/templates/code-analysis/nodes/finalize-node.js +118 -0
- package/templates/code-analysis/nodes/generate-code-node.js +425 -0
- package/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
- package/templates/code-analysis/nodes/services/prMetaService.js +86 -0
- package/templates/code-analysis/nodes/setup-node.js +142 -0
- package/templates/code-analysis/prompts/analyze-ticket.md +181 -0
- package/templates/code-analysis/prompts/generate-code.md +33 -0
- package/templates/code-analysis/prompts/generate-test-cases.md +110 -0
- package/templates/code-analysis/state.js +40 -0
- package/templates/code-implementation/graph.js +35 -0
- package/templates/code-implementation/index.js +7 -0
- package/templates/code-implementation/state.js +14 -0
- package/templates/global-setup.js +56 -0
- package/templates/index.js +94 -0
- package/templates/register-nodes.js +24 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TraceParser - Extracts exact selectors from Playwright trace.zip files
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
|
|
10
|
+
export class TraceParser {
|
|
11
|
+
static async parseTraceZip(traceDirPath) {
|
|
12
|
+
let traceFilePath;
|
|
13
|
+
|
|
14
|
+
if (traceDirPath.endsWith('.zip') && existsSync(traceDirPath)) {
|
|
15
|
+
const tempDir = join(tmpdir(), `trace-${Date.now()}`);
|
|
16
|
+
execSync(`unzip -q "${traceDirPath}" -d "${tempDir}"`, { stdio: 'pipe' });
|
|
17
|
+
|
|
18
|
+
// Find the actual .trace file (trace-{timestamp}.trace, not trace.trace)
|
|
19
|
+
const files = readdirSync(tempDir);
|
|
20
|
+
const traceFile = files.find(f => f.endsWith('.trace'));
|
|
21
|
+
if (!traceFile) throw new Error('No .trace file found in zip');
|
|
22
|
+
traceFilePath = join(tempDir, traceFile);
|
|
23
|
+
} else if (existsSync(traceDirPath)) {
|
|
24
|
+
const files = readdirSync(traceDirPath);
|
|
25
|
+
const traceFile = files.find(f => f.endsWith('.trace'));
|
|
26
|
+
if (!traceFile) throw new Error('No .trace file found');
|
|
27
|
+
traceFilePath = join(traceDirPath, traceFile);
|
|
28
|
+
} else {
|
|
29
|
+
throw new Error(`Trace not found at ${traceDirPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const traceContent = readFileSync(traceFilePath, 'utf-8');
|
|
34
|
+
const lines = traceContent.trim().split('\n');
|
|
35
|
+
const actions = [];
|
|
36
|
+
|
|
37
|
+
// NEW: Extract accessibility tree snapshots (contains ACTUAL text)
|
|
38
|
+
const accessibilitySnapshots = new Map(); // snapshotName -> Map<ref, node>
|
|
39
|
+
|
|
40
|
+
// Extract DOM snapshots from trace events
|
|
41
|
+
const domSnapshots = new Map();
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
try {
|
|
44
|
+
const event = JSON.parse(line);
|
|
45
|
+
|
|
46
|
+
// NEW: Parse accessibility snapshots
|
|
47
|
+
if (event.type === 'snapshot' && event.snapshot && event.snapshot.accessibility) {
|
|
48
|
+
const axTree = new Map();
|
|
49
|
+
for (const node of event.snapshot.accessibility) {
|
|
50
|
+
if (node.ref) {
|
|
51
|
+
axTree.set(node.ref, node);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
accessibilitySnapshots.set(event.snapshotName, axTree);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Look for frame-snapshot events (contains DOM HTML)
|
|
58
|
+
if (event.type === 'frame-snapshot' && event.snapshot) {
|
|
59
|
+
const html = Buffer.from(event.snapshot.html || '', 'base64').toString('utf-8');
|
|
60
|
+
if (html && html.length > 100) { // Only store meaningful snapshots
|
|
61
|
+
domSnapshots.set(event.pageId || 'default', html);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore snapshot storage errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
try {
|
|
71
|
+
const event = JSON.parse(line);
|
|
72
|
+
if (event.type === 'before' && event.params && event.params.selector) {
|
|
73
|
+
const method = event.method;
|
|
74
|
+
if (['click', 'fill', 'type', 'selectOption'].includes(method)) {
|
|
75
|
+
const selector = event.params.selector;
|
|
76
|
+
const text = event.params.text || event.params.value || '';
|
|
77
|
+
|
|
78
|
+
const strategies = [];
|
|
79
|
+
|
|
80
|
+
// NEW: Extract ACTUAL text from accessibility tree snapshot
|
|
81
|
+
let actualText = null;
|
|
82
|
+
let actualRole = null;
|
|
83
|
+
let actualAriaLabel = null;
|
|
84
|
+
|
|
85
|
+
const ariaRefMatch = selector.match(/aria-ref=([a-z0-9]+)/i);
|
|
86
|
+
if (ariaRefMatch && event.snapshotName) {
|
|
87
|
+
const ref = ariaRefMatch[1];
|
|
88
|
+
const snapshot = accessibilitySnapshots.get(event.snapshotName);
|
|
89
|
+
if (snapshot && snapshot.has(ref)) {
|
|
90
|
+
const node = snapshot.get(ref);
|
|
91
|
+
actualText = node.name || null; // ACTUAL text from DOM
|
|
92
|
+
actualRole = node.role || null; // ACTUAL role
|
|
93
|
+
actualAriaLabel = node.label || null; // ACTUAL aria-label
|
|
94
|
+
|
|
95
|
+
console.log(`[TraceParser] ✅ Found ACTUAL element data: text="${actualText}", role="${actualRole}"`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 1. Extract every possible robust anchor from the internal selector
|
|
100
|
+
const textMatch = selector.match(/internal:text="([^"]+)"/i);
|
|
101
|
+
const labelMatch = selector.match(/internal:label="([^"]+)"/i);
|
|
102
|
+
const placeholderMatch = selector.match(/internal:placeholder="([^"]+)"/i);
|
|
103
|
+
const roleMatch = selector.match(/internal:role=([^ ]+)/i);
|
|
104
|
+
const describeMatch = selector.match(/internal:describe="([^"]+)"/i);
|
|
105
|
+
const nameMatch = selector.match(/name="([^"]+)"/i);
|
|
106
|
+
|
|
107
|
+
if (textMatch) {
|
|
108
|
+
strategies.push({ type: 'text', text: textMatch[1] });
|
|
109
|
+
// Add fuzzy text variants
|
|
110
|
+
const words = textMatch[1].split(' ');
|
|
111
|
+
if (words.length > 1) {
|
|
112
|
+
strategies.push({ type: 'text', text: words[0], fuzzy: true });
|
|
113
|
+
strategies.push({ type: 'text', text: words[words.length - 1], fuzzy: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (labelMatch) strategies.push({ type: 'label', label: labelMatch[1] });
|
|
117
|
+
if (placeholderMatch) strategies.push({ type: 'placeholder', placeholder: placeholderMatch[1] });
|
|
118
|
+
|
|
119
|
+
// NEW: PRIORITIZE ACTUAL TEXT from accessibility tree
|
|
120
|
+
if (actualText) {
|
|
121
|
+
strategies.unshift({ type: 'text', text: actualText, source: 'accessibility-tree' });
|
|
122
|
+
// Add fuzzy variants
|
|
123
|
+
const words = actualText.split(' ');
|
|
124
|
+
if (words.length > 1) {
|
|
125
|
+
strategies.push({ type: 'text', text: words[0], fuzzy: true, source: 'accessibility-tree' });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (actualRole || roleMatch) {
|
|
130
|
+
const role = actualRole || roleMatch[1];
|
|
131
|
+
const name = actualText || (nameMatch ? nameMatch[1] : (textMatch ? textMatch[1] : null));
|
|
132
|
+
strategies.unshift({ type: 'role', role, name, source: actualText ? 'accessibility-tree' : 'selector' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (actualAriaLabel) {
|
|
136
|
+
strategies.unshift({ type: 'label', label: actualAriaLabel, source: 'accessibility-tree' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Parse internal:describe (used by playwright-official)
|
|
140
|
+
if (describeMatch) {
|
|
141
|
+
const description = describeMatch[1];
|
|
142
|
+
const roleKeywords = ['link', 'button', 'textbox', 'menuitem', 'submenu', 'combobox', 'checkbox', 'radio', 'tab', 'treeitem', 'menu item'];
|
|
143
|
+
|
|
144
|
+
// Check if it ends with a role keyword
|
|
145
|
+
let matchedRole = null;
|
|
146
|
+
let cleanName = description;
|
|
147
|
+
|
|
148
|
+
for (const r of roleKeywords) {
|
|
149
|
+
if (description.toLowerCase().endsWith(` ${r}`)) {
|
|
150
|
+
matchedRole = r.replace(' ', ''); // Handle "menu item" -> "menuitem"
|
|
151
|
+
cleanName = description.substring(0, description.length - r.length - 1);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (matchedRole) {
|
|
157
|
+
strategies.push({ type: 'role', role: matchedRole, name: cleanName });
|
|
158
|
+
// Strip English descriptions in parentheses for cleaner text matching
|
|
159
|
+
const textName = cleanName.replace(/\s*\([^)]+\)\s*$/, '');
|
|
160
|
+
strategies.push({ type: 'text', text: textName });
|
|
161
|
+
strategies.push({ type: 'text', text: cleanName });
|
|
162
|
+
|
|
163
|
+
// Add fuzzy text variants
|
|
164
|
+
const words = cleanName.split(' ');
|
|
165
|
+
if (words.length > 1) {
|
|
166
|
+
strategies.push({ type: 'text', text: words[0], fuzzy: true });
|
|
167
|
+
strategies.push({ type: 'text', text: words.slice(0, 2).join(' '), fuzzy: true });
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
const textName = description.replace(/\s*\([^)]+\)\s*$/, '');
|
|
171
|
+
strategies.push({ type: 'text', text: textName });
|
|
172
|
+
strategies.push({ type: 'text', text: description });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. Extract data-testid, classes, IDs from DOM snapshots
|
|
177
|
+
const domStrategies = this.extractDOMStrategies(selector, domSnapshots, textMatch?.[1] || describeMatch?.[1], event.pageId);
|
|
178
|
+
strategies.push(...domStrategies);
|
|
179
|
+
|
|
180
|
+
// Extract structural context (parent/sibling) from CSS selector chain
|
|
181
|
+
const structuralContext = this.extractStructuralContext(selector);
|
|
182
|
+
|
|
183
|
+
// Add structural context to high-priority strategies
|
|
184
|
+
if (structuralContext.parent || structuralContext.sibling) {
|
|
185
|
+
strategies.forEach(strat => {
|
|
186
|
+
if (['role', 'text', 'label', 'testid'].includes(strat.type)) {
|
|
187
|
+
if (structuralContext.parent) strat.parent = structuralContext.parent;
|
|
188
|
+
if (structuralContext.sibling) strat.sibling = structuralContext.sibling;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Always add the raw CSS as a final fallback
|
|
194
|
+
strategies.push({ type: 'css', value: selector });
|
|
195
|
+
|
|
196
|
+
// NEW: Use ACTUAL text if available, fallback to AI description
|
|
197
|
+
const finalName = actualText ||
|
|
198
|
+
textMatch ? textMatch[1] :
|
|
199
|
+
(describeMatch ? describeMatch[1].replace(/\s*\([^)]+\)\s*$/, '') :
|
|
200
|
+
`Action ${actions.length}`);
|
|
201
|
+
|
|
202
|
+
actions.push({
|
|
203
|
+
method,
|
|
204
|
+
name: finalName,
|
|
205
|
+
action: method === 'type' ? 'fill' : method,
|
|
206
|
+
value: text,
|
|
207
|
+
strategies,
|
|
208
|
+
timestamp: event.startTime,
|
|
209
|
+
// NEW: Include metadata for debugging
|
|
210
|
+
actualText,
|
|
211
|
+
actualRole,
|
|
212
|
+
actualAriaLabel
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// Ignore trace entry parsing errors
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return actions;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new Error(`Failed to parse trace: ${error.message}`, { cause: error });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract data-testid, classes, IDs, and DOM paths from snapshots
|
|
228
|
+
*/
|
|
229
|
+
static extractDOMStrategies(selector, domSnapshots, textHint, pageId) {
|
|
230
|
+
const strategies = [];
|
|
231
|
+
|
|
232
|
+
if (!domSnapshots || domSnapshots.size === 0 || !textHint) {
|
|
233
|
+
return strategies;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// Get the snapshot for this page
|
|
238
|
+
const html = domSnapshots.get(pageId);
|
|
239
|
+
if (!html) {
|
|
240
|
+
return strategies;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Escape special regex characters in textHint
|
|
244
|
+
const escapedHint = textHint.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
245
|
+
|
|
246
|
+
// Extract data-testid patterns
|
|
247
|
+
const testidRegex = new RegExp(`data-testid=["']([^"']+)["'][^>]*>[^<]*${escapedHint}`, 'i');
|
|
248
|
+
const testidMatch = html.match(testidRegex);
|
|
249
|
+
if (testidMatch) {
|
|
250
|
+
strategies.push({ type: 'testid', value: testidMatch[1], priority: 'high' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Extract stable class names (not dynamic like css-123abc)
|
|
254
|
+
const classRegex = new RegExp(`class=["']([^"']+)["'][^>]*>[^<]*${escapedHint}`, 'gi');
|
|
255
|
+
const classMatches = html.matchAll(classRegex);
|
|
256
|
+
for (const match of classMatches) {
|
|
257
|
+
const classes = match[1].split(/\s+/).filter(c =>
|
|
258
|
+
c && !c.match(/^(css|jss|makeStyles|MuiBox|MuiStack)-\w+/) && c.length > 2
|
|
259
|
+
);
|
|
260
|
+
if (classes.length > 0) {
|
|
261
|
+
strategies.push({ type: 'class', value: classes.join('.'), priority: 'medium' });
|
|
262
|
+
if (classes.length === 1) {
|
|
263
|
+
strategies.push({ type: 'class', value: classes[0], priority: 'medium' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Extract stable IDs (not dynamic)
|
|
269
|
+
const idRegex = new RegExp(`id=["']([^"']+)["'][^>]*>[^<]*${escapedHint}`, 'i');
|
|
270
|
+
const idMatch = html.match(idRegex);
|
|
271
|
+
if (idMatch && !idMatch[1].match(/^(root|app|\d+|[a-f0-9-]{20,})$/i)) {
|
|
272
|
+
strategies.push({ type: 'id', value: idMatch[1], priority: 'high' });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
} catch (e) {
|
|
276
|
+
// Silent fail - snapshots are optional enhancement
|
|
277
|
+
console.warn(`[TraceParser] DOM extraction failed: ${e.message}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return strategies;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Extract parent/sibling context from Playwright internal selector chains
|
|
285
|
+
* Example: "form[name='login'] >> button" => parent: "form[name='login']"
|
|
286
|
+
*/
|
|
287
|
+
static extractStructuralContext(selector) {
|
|
288
|
+
const context = { parent: null, sibling: null };
|
|
289
|
+
|
|
290
|
+
// Split by >> to get hierarchy (Playwright's chaining operator)
|
|
291
|
+
const parts = selector.split('>>').map(p => p.trim());
|
|
292
|
+
|
|
293
|
+
if (parts.length > 1) {
|
|
294
|
+
// The second-to-last part is likely the parent container
|
|
295
|
+
const parentPart = parts[parts.length - 2];
|
|
296
|
+
|
|
297
|
+
// Extract clean CSS from internal: prefixes
|
|
298
|
+
const cleanParent = parentPart
|
|
299
|
+
.replace(/internal:text="[^"]+"\s*/gi, '')
|
|
300
|
+
.replace(/internal:role=\S+\s*/gi, '')
|
|
301
|
+
.replace(/internal:label="[^"]+"\s*/gi, '')
|
|
302
|
+
.trim();
|
|
303
|
+
|
|
304
|
+
// Only use if it's a meaningful selector (form, div with id/class, etc)
|
|
305
|
+
if (cleanParent && (
|
|
306
|
+
cleanParent.match(/^(form|section|nav|header|aside|main|article)\b/) ||
|
|
307
|
+
cleanParent.includes('[') ||
|
|
308
|
+
cleanParent.match(/[#.]\w/)
|
|
309
|
+
)) {
|
|
310
|
+
context.parent = cleanParent;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Extract sibling hints from CSS selectors like "input[type='email'] + button"
|
|
315
|
+
const lastPart = parts[parts.length - 1];
|
|
316
|
+
const siblingMatch = lastPart.match(/([^+~]+)\s*[+~]\s*(.+)/);
|
|
317
|
+
if (siblingMatch) {
|
|
318
|
+
context.sibling = siblingMatch[1].trim();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return context;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default TraceParser;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readdir, access, copyFile, constants } from 'fs/promises';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
|
|
4
|
+
export async function organizeVideos(options = {}) {
|
|
5
|
+
const {
|
|
6
|
+
testResultsDir = 'test-results',
|
|
7
|
+
testsDir = 'tests',
|
|
8
|
+
projectRoot = process.cwd(),
|
|
9
|
+
verbose = true,
|
|
10
|
+
} = options;
|
|
11
|
+
|
|
12
|
+
if (verbose) {
|
|
13
|
+
console.log('🎥 Organizing test videos...\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const resultsPath = join(projectRoot, testResultsDir);
|
|
17
|
+
const testsPath = join(projectRoot, testsDir);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const results = await readdir(resultsPath);
|
|
21
|
+
let movedCount = 0;
|
|
22
|
+
|
|
23
|
+
for (const resultDir of results) {
|
|
24
|
+
if (resultDir.startsWith('.')) continue;
|
|
25
|
+
|
|
26
|
+
const videoPath = join(resultsPath, resultDir, 'video.webm');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await access(videoPath, constants.F_OK);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const testName = resultDir
|
|
35
|
+
.replace(/-chromium$/, '')
|
|
36
|
+
.replace(/-firefox$/, '')
|
|
37
|
+
.replace(/-webkit$/, '');
|
|
38
|
+
|
|
39
|
+
const testFile = await findTestFile(testsPath, testName);
|
|
40
|
+
|
|
41
|
+
if (testFile) {
|
|
42
|
+
const videoName = testFile.replace(/\.spec\.(js|ts)$/, '.spec.webm');
|
|
43
|
+
|
|
44
|
+
await copyFile(videoPath, videoName);
|
|
45
|
+
|
|
46
|
+
if (verbose) {
|
|
47
|
+
console.log(`✅ ${relative(projectRoot, videoName)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
movedCount++;
|
|
51
|
+
} else if (verbose) {
|
|
52
|
+
console.log(`⚠️ Could not find test file for: ${resultDir}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (verbose) {
|
|
57
|
+
console.log(`\n🎬 Organized ${movedCount} video(s)`);
|
|
58
|
+
console.log(`📂 Videos are now next to their test files in ${testsDir}/`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { movedCount, success: true };
|
|
62
|
+
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (verbose) {
|
|
65
|
+
console.error('❌ Error organizing videos:', error.message);
|
|
66
|
+
}
|
|
67
|
+
return { movedCount: 0, success: false, error: error.message };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function findTestFile(testsDir, testName) {
|
|
72
|
+
const parts = testName.split('-');
|
|
73
|
+
|
|
74
|
+
for (let i = parts.length; i > 0; i--) {
|
|
75
|
+
const pathParts = parts.slice(0, i);
|
|
76
|
+
const searchPath = pathParts.join('/');
|
|
77
|
+
|
|
78
|
+
for (const ext of ['js', 'ts']) {
|
|
79
|
+
const testPath = join(testsDir, `${searchPath}.spec.${ext}`);
|
|
80
|
+
try {
|
|
81
|
+
await access(testPath, constants.F_OK);
|
|
82
|
+
return testPath;
|
|
83
|
+
} catch {
|
|
84
|
+
// File doesn't exist, continue search
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Browser Test Automation Workflow
|
|
2
|
+
|
|
3
|
+
This is YOUR workflow graph. You can customize it however you want!
|
|
4
|
+
|
|
5
|
+
Works with **Claude** or **Cursor** agents (configured in `.zibby.config.js`).
|
|
6
|
+
|
|
7
|
+
## Default Flow
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
preflight → execute_live → generate_script
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The workflow generates a test title, executes the test live in a **browser** with AI assistance, and generates a Playwright script with stable selectors.
|
|
14
|
+
|
|
15
|
+
## Customization
|
|
16
|
+
|
|
17
|
+
### Add Custom Nodes
|
|
18
|
+
|
|
19
|
+
Create a new file in `nodes/`:
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
// nodes/send-slack.js
|
|
23
|
+
export const sendSlackNode = {
|
|
24
|
+
name: 'send_slack',
|
|
25
|
+
agent: { type: 'openai', model: 'gpt-4o-mini' },
|
|
26
|
+
prompt: (state) => `Send Slack notification...`,
|
|
27
|
+
outputSchema: { success: { type: 'boolean', required: true } }
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then add it to your graph in `graph.js`:
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
import { sendSlackNode } from './nodes/send-slack.js';
|
|
35
|
+
|
|
36
|
+
buildGraph() {
|
|
37
|
+
const graph = new WorkflowGraph();
|
|
38
|
+
// ... existing nodes
|
|
39
|
+
graph.addNode('send_slack', sendSlackNode);
|
|
40
|
+
graph.addEdge('verify_script', 'send_slack');
|
|
41
|
+
return graph;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Multi-Agent Configuration
|
|
46
|
+
|
|
47
|
+
Each node can use a different LLM:
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
graph.addNode('generate_title', {
|
|
51
|
+
agent: { type: 'claude', model: 'claude-sonnet-4' },
|
|
52
|
+
prompt: (state) => `Generate title...`
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
graph.addNode('verify_script', {
|
|
56
|
+
agent: { type: 'deepseek', model: 'deepseek-coder' }, // Cheap & fast
|
|
57
|
+
prompt: (state) => `Run test...`
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
graph.addNode('update_jira', {
|
|
61
|
+
agent: { type: 'ollama', model: 'llama3' }, // Local for privacy
|
|
62
|
+
prompt: (state) => `Update Jira...`
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Skip Nodes
|
|
67
|
+
|
|
68
|
+
Comment out nodes you don't need:
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
// graph.addNode('verify_script', verifyScriptNode);
|
|
72
|
+
graph.addEdge('generate_script', 'update_jira'); // Skip verification
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Parallel Execution
|
|
76
|
+
|
|
77
|
+
Run multiple nodes in parallel:
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
graph.addParallelEdges('verify_script', [
|
|
81
|
+
'send_slack',
|
|
82
|
+
'update_jira',
|
|
83
|
+
'log_datadog'
|
|
84
|
+
]);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Edit `.zibby/config.js` to set default agents per node:
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
export default {
|
|
93
|
+
agents: {
|
|
94
|
+
execute_live: { type: 'cursor' },
|
|
95
|
+
verify_script: { type: 'deepseek', model: 'deepseek-coder' },
|
|
96
|
+
update_jira: { type: 'ollama', model: 'llama3' }
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Documentation
|
|
102
|
+
|
|
103
|
+
- [Full Graph Framework Design](../../docs/GRAPH_FRAMEWORK_DESIGN.md)
|
|
104
|
+
- [Multi-Agent Patterns](../../docs/FRAMEWORK_CONVERSATION_SUMMARY.md)
|
|
105
|
+
|
|
106
|
+
## Updates
|
|
107
|
+
|
|
108
|
+
To get latest template updates:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
zibby update-graph --merge
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This will merge bug fixes while preserving your customizations.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Automation Workflow Graph
|
|
3
|
+
*
|
|
4
|
+
* buildGraph() - define nodes, edges, routing
|
|
5
|
+
* onComplete(result) - post-processing after graph finishes (save artifacts, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
9
|
+
import { memoryMiddleware } from '@zibby/memory';
|
|
10
|
+
import {
|
|
11
|
+
preflightNode,
|
|
12
|
+
executeLiveNode,
|
|
13
|
+
generateScriptNode,
|
|
14
|
+
} from './nodes/index.js';
|
|
15
|
+
import { BrowserTestResultHandler } from './result-handler.js';
|
|
16
|
+
|
|
17
|
+
export class BrowserTestAutomationAgent extends WorkflowAgent {
|
|
18
|
+
buildGraph() {
|
|
19
|
+
const graph = new WorkflowGraph({
|
|
20
|
+
middleware: [memoryMiddleware()].filter(Boolean),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
graph.addNode('preflight', preflightNode);
|
|
24
|
+
graph.addNode('execute_live', executeLiveNode);
|
|
25
|
+
graph.addNode('generate_script', generateScriptNode);
|
|
26
|
+
|
|
27
|
+
graph.setEntryPoint('preflight');
|
|
28
|
+
graph.addEdge('preflight', 'execute_live');
|
|
29
|
+
|
|
30
|
+
graph.addConditionalEdges('execute_live', (state) => {
|
|
31
|
+
const result = state.execute_live;
|
|
32
|
+
const hasExecution = (result?.steps?.length > 0) || (result?.actions?.length > 0);
|
|
33
|
+
return hasExecution ? 'generate_script' : 'END';
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
graph.addEdge('generate_script', 'END');
|
|
37
|
+
return graph;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async onComplete(result) {
|
|
41
|
+
const cwd = result.state.cwd || process.cwd();
|
|
42
|
+
BrowserTestResultHandler.saveTitle(result, cwd);
|
|
43
|
+
await BrowserTestResultHandler.saveExecutionData(result);
|
|
44
|
+
|
|
45
|
+
if (process.env.ZIBBY_MEMORY) {
|
|
46
|
+
try {
|
|
47
|
+
const { memoryEndRun, memorySyncPush } = await import('@zibby/memory');
|
|
48
|
+
const sessionId = result.state.sessionPath?.split('/').pop();
|
|
49
|
+
memoryEndRun(cwd, { sessionId, passed: result.success !== false });
|
|
50
|
+
memorySyncPush(cwd);
|
|
51
|
+
} catch { /* @zibby/memory not available */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|