@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +94 -0
  4. package/src/agents/base.js +361 -0
  5. package/src/constants.js +47 -0
  6. package/src/enrichment/base.js +49 -0
  7. package/src/enrichment/enrichers/accessibility-enricher.js +197 -0
  8. package/src/enrichment/enrichers/dom-enricher.js +171 -0
  9. package/src/enrichment/enrichers/page-state-enricher.js +129 -0
  10. package/src/enrichment/enrichers/position-enricher.js +67 -0
  11. package/src/enrichment/index.js +96 -0
  12. package/src/enrichment/mcp-integration.js +149 -0
  13. package/src/enrichment/mcp-ref-enricher.js +78 -0
  14. package/src/enrichment/pipeline.js +192 -0
  15. package/src/enrichment/trace-text-enricher.js +115 -0
  16. package/src/framework/AGENTS.md +98 -0
  17. package/src/framework/agents/base.js +72 -0
  18. package/src/framework/agents/claude-strategy.js +278 -0
  19. package/src/framework/agents/cursor-strategy.js +459 -0
  20. package/src/framework/agents/index.js +105 -0
  21. package/src/framework/agents/utils/cursor-output-formatter.js +67 -0
  22. package/src/framework/agents/utils/openai-proxy-formatter.js +249 -0
  23. package/src/framework/code-generator.js +301 -0
  24. package/src/framework/constants.js +33 -0
  25. package/src/framework/context-loader.js +101 -0
  26. package/src/framework/function-bridge.js +78 -0
  27. package/src/framework/function-skill-registry.js +20 -0
  28. package/src/framework/graph-compiler.js +342 -0
  29. package/src/framework/graph.js +610 -0
  30. package/src/framework/index.js +28 -0
  31. package/src/framework/node-registry.js +163 -0
  32. package/src/framework/node.js +259 -0
  33. package/src/framework/output-parser.js +71 -0
  34. package/src/framework/skill-registry.js +55 -0
  35. package/src/framework/state-utils.js +52 -0
  36. package/src/framework/state.js +67 -0
  37. package/src/framework/tool-resolver.js +65 -0
  38. package/src/index.js +342 -0
  39. package/src/runtime/generation/base.js +46 -0
  40. package/src/runtime/generation/index.js +70 -0
  41. package/src/runtime/generation/mcp-ref-strategy.js +197 -0
  42. package/src/runtime/generation/stable-id-strategy.js +170 -0
  43. package/src/runtime/stable-id-runtime.js +248 -0
  44. package/src/runtime/verification/base.js +44 -0
  45. package/src/runtime/verification/index.js +67 -0
  46. package/src/runtime/verification/playwright-json-strategy.js +119 -0
  47. package/src/runtime/zibby-runtime.js +299 -0
  48. package/src/sync/index.js +2 -0
  49. package/src/sync/uploader.js +29 -0
  50. package/src/tools/run-playwright-test.js +158 -0
  51. package/src/utils/adf-converter.js +68 -0
  52. package/src/utils/ast-utils.js +37 -0
  53. package/src/utils/ci-setup.js +124 -0
  54. package/src/utils/cursor-utils.js +71 -0
  55. package/src/utils/logger.js +144 -0
  56. package/src/utils/mcp-config-writer.js +115 -0
  57. package/src/utils/node-schema-parser.js +522 -0
  58. package/src/utils/post-process-events.js +55 -0
  59. package/src/utils/result-handler.js +102 -0
  60. package/src/utils/ripple-effect.js +84 -0
  61. package/src/utils/selector-generator.js +239 -0
  62. package/src/utils/streaming-parser.js +387 -0
  63. package/src/utils/test-post-processor.js +211 -0
  64. package/src/utils/timeline.js +217 -0
  65. package/src/utils/trace-parser.js +325 -0
  66. package/src/utils/video-organizer.js +91 -0
  67. package/templates/browser-test-automation/README.md +114 -0
  68. package/templates/browser-test-automation/graph.js +54 -0
  69. package/templates/browser-test-automation/nodes/execute-live.js +250 -0
  70. package/templates/browser-test-automation/nodes/generate-script.js +77 -0
  71. package/templates/browser-test-automation/nodes/index.js +3 -0
  72. package/templates/browser-test-automation/nodes/preflight.js +59 -0
  73. package/templates/browser-test-automation/nodes/utils.js +154 -0
  74. package/templates/browser-test-automation/result-handler.js +286 -0
  75. package/templates/code-analysis/graph.js +72 -0
  76. package/templates/code-analysis/index.js +18 -0
  77. package/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
  78. package/templates/code-analysis/nodes/create-pr-node.js +175 -0
  79. package/templates/code-analysis/nodes/finalize-node.js +118 -0
  80. package/templates/code-analysis/nodes/generate-code-node.js +425 -0
  81. package/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
  82. package/templates/code-analysis/nodes/services/prMetaService.js +86 -0
  83. package/templates/code-analysis/nodes/setup-node.js +142 -0
  84. package/templates/code-analysis/prompts/analyze-ticket.md +181 -0
  85. package/templates/code-analysis/prompts/generate-code.md +33 -0
  86. package/templates/code-analysis/prompts/generate-test-cases.md +110 -0
  87. package/templates/code-analysis/state.js +40 -0
  88. package/templates/code-implementation/graph.js +35 -0
  89. package/templates/code-implementation/index.js +7 -0
  90. package/templates/code-implementation/state.js +14 -0
  91. package/templates/global-setup.js +56 -0
  92. package/templates/index.js +94 -0
  93. 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
+ }