@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,197 @@
|
|
|
1
|
+
import { TestGenerationStrategy } from './base.js';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MCPRefStrategy - Generates tests using MCP element descriptions
|
|
6
|
+
*
|
|
7
|
+
* This strategy generates tests that use the EXACT elements that were
|
|
8
|
+
* interacted with during recording, identified by their MCP ref IDs.
|
|
9
|
+
*
|
|
10
|
+
* Reliability: 99% - Works as long as the DOM structure is similar between runs
|
|
11
|
+
*/
|
|
12
|
+
export class MCPRefStrategy extends TestGenerationStrategy {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(
|
|
15
|
+
'mcp-ref',
|
|
16
|
+
'MCP Reference Replay (Exact 1:1)',
|
|
17
|
+
200 // Highest priority - most accurate
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
canGenerate(context) {
|
|
22
|
+
// Check if events have MCP element descriptions
|
|
23
|
+
const eventsPath = context.eventsPath || `${context.sessionPath}/execute_live/events.json`;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
|
|
27
|
+
const hasMCPElements = events.some(e => e.data?.params?.element);
|
|
28
|
+
|
|
29
|
+
if (!hasMCPElements) {
|
|
30
|
+
console.log('[MCPRefStrategy] ❌ No MCP element descriptions found in events');
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log('[MCPRefStrategy] ✅ MCP element descriptions available');
|
|
35
|
+
return true;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.log('[MCPRefStrategy] ❌ Failed to read events:', e.message);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getName() {
|
|
43
|
+
return 'MCP Reference Replay (Exact 1:1)';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getPriority() {
|
|
47
|
+
return 200;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async generate(context) {
|
|
51
|
+
const { testFilePath, sessionPath, state } = context;
|
|
52
|
+
const eventsPath = `${sessionPath}/execute_live/events.json`;
|
|
53
|
+
const testTitle = state?.title || 'Generated Test';
|
|
54
|
+
|
|
55
|
+
console.log('[MCPRefStrategy] 🎯 Generating test using MCP element descriptions (1:1 replay)');
|
|
56
|
+
console.log(`[MCPRefStrategy] events: ${eventsPath}`);
|
|
57
|
+
console.log(`[MCPRefStrategy] output: ${testFilePath}`);
|
|
58
|
+
|
|
59
|
+
const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
|
|
60
|
+
const actionEvents = events.filter(e =>
|
|
61
|
+
['navigate', 'type', 'fill', 'click', 'select_option'].includes(e.type)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
let testCode = `import { test, expect } from '@playwright/test';\n`;
|
|
65
|
+
testCode += `import { ZibbyRuntime } from '@zibby/core';\n\n`;
|
|
66
|
+
testCode += `test('${testTitle}', async ({ page }) => {\n`;
|
|
67
|
+
testCode += ` const timestamp = Date.now();\n\n`;
|
|
68
|
+
|
|
69
|
+
for (const event of actionEvents) {
|
|
70
|
+
const mcpElement = event.data?.params?.element || 'element';
|
|
71
|
+
const _ref = event.data?.params?.ref;
|
|
72
|
+
|
|
73
|
+
// Priority order for element text (handles Chinese/multi-language):
|
|
74
|
+
// 1. traceActualText - extracted from accessibility tree in trace.zip (MOST RELIABLE)
|
|
75
|
+
// 2. traceActualRole - extracted from accessibility tree (ACTUAL role)
|
|
76
|
+
// 3. traceActualAriaLabel - extracted from accessibility tree (ACTUAL aria-label)
|
|
77
|
+
// 4. actualText - captured during live recording (if available)
|
|
78
|
+
// 5. mcpElement - AI's description (fallback)
|
|
79
|
+
const traceText = event.enrichedData?.traceActualText;
|
|
80
|
+
const traceRole = event.enrichedData?.traceActualRole;
|
|
81
|
+
const _traceLabel = event.enrichedData?.traceActualAriaLabel;
|
|
82
|
+
const liveText = event.enrichedData?.actualText;
|
|
83
|
+
const actualText = traceText || liveText;
|
|
84
|
+
const actualRole = traceRole || event.enrichedData?.actualRole;
|
|
85
|
+
|
|
86
|
+
// Use FULL description for text matching (don't strip role suffix)
|
|
87
|
+
const _fullText = actualText || mcpElement;
|
|
88
|
+
// Use name only (without role) for getByRole
|
|
89
|
+
const nameOnly = actualText || this._extractName(mcpElement);
|
|
90
|
+
const selectorRole = actualRole || this._extractRole(mcpElement);
|
|
91
|
+
|
|
92
|
+
const sourceNote = traceText ? ' [accessibility-tree]' : (liveText ? ' [live]' : ' [AI]');
|
|
93
|
+
|
|
94
|
+
if (event.type === 'navigate') {
|
|
95
|
+
testCode += ` await page.goto('${event.data.params.url}');\n\n`;
|
|
96
|
+
} else if (event.type === 'click') {
|
|
97
|
+
testCode += ` // ${mcpElement}${actualText ? ` (actual: "${actualText}")${sourceNote}` : ''}\n`;
|
|
98
|
+
testCode += ` await ZibbyRuntime.step(page, {\n`;
|
|
99
|
+
testCode += ` name: '${this._escapeString(mcpElement)}',\n`;
|
|
100
|
+
testCode += ` action: 'click',\n`;
|
|
101
|
+
testCode += ` strategies: [\n`;
|
|
102
|
+
|
|
103
|
+
// 95% RELIABLE STRATEGY:
|
|
104
|
+
// Priority 1: Use getByRole with the EXACT description from trace
|
|
105
|
+
// Playwright aggregates ALL nested text automatically, so "HR Our core HR system button"
|
|
106
|
+
// will match even if text is split across <div>HR</div><div>Our core HR system</div>
|
|
107
|
+
testCode += ` { type: 'role', role: '${selectorRole}', name: '${this._escapeString(nameOnly)}' },\n`;
|
|
108
|
+
|
|
109
|
+
// Priority 2: Text search (Playwright searches ALL nested text)
|
|
110
|
+
testCode += ` { type: 'text', text: '${this._escapeString(nameOnly)}' }\n`;
|
|
111
|
+
|
|
112
|
+
testCode += ` ]\n`;
|
|
113
|
+
testCode += ` });\n\n`;
|
|
114
|
+
} else if (event.type === 'fill' || event.type === 'type') {
|
|
115
|
+
const value = event.data.params.text;
|
|
116
|
+
testCode += ` // ${mcpElement}${actualText ? ` (actual: "${actualText}")${sourceNote}` : ''}\n`;
|
|
117
|
+
testCode += ` await ZibbyRuntime.step(page, {\n`;
|
|
118
|
+
testCode += ` name: '${this._escapeString(mcpElement)}',\n`;
|
|
119
|
+
testCode += ` action: 'fill',\n`;
|
|
120
|
+
testCode += ` value: '${this._escapeString(value)}',\n`;
|
|
121
|
+
testCode += ` strategies: [\n`;
|
|
122
|
+
testCode += ` { type: 'role', role: '${selectorRole}', name: '${this._escapeString(nameOnly)}' },\n`;
|
|
123
|
+
testCode += ` { type: 'attributes', placeholder: '${this._escapeString(nameOnly)}' }\n`;
|
|
124
|
+
testCode += ` ]\n`;
|
|
125
|
+
testCode += ` });\n\n`;
|
|
126
|
+
} else if (event.type === 'select_option') {
|
|
127
|
+
const values = event.data.params.values;
|
|
128
|
+
const value = Array.isArray(values) ? values[0] : values;
|
|
129
|
+
testCode += ` // ${mcpElement}${actualText ? ` (actual: "${actualText}")${sourceNote}` : ''}\n`;
|
|
130
|
+
testCode += ` await ZibbyRuntime.step(page, {\n`;
|
|
131
|
+
testCode += ` name: '${this._escapeString(mcpElement)}',\n`;
|
|
132
|
+
testCode += ` action: 'select',\n`;
|
|
133
|
+
testCode += ` value: '${this._escapeString(value)}',\n`;
|
|
134
|
+
testCode += ` strategies: [\n`;
|
|
135
|
+
testCode += ` { type: 'role', role: 'combobox', name: '${this._escapeString(nameOnly)}' }\n`;
|
|
136
|
+
testCode += ` ]\n`;
|
|
137
|
+
testCode += ` });\n\n`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
testCode += `});\n`;
|
|
142
|
+
|
|
143
|
+
writeFileSync(testFilePath, testCode);
|
|
144
|
+
|
|
145
|
+
console.log(`[MCPRefStrategy] ✅ Generated test with ${actionEvents.length} actions using MCP descriptions`);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
success: true,
|
|
149
|
+
testPath: testFilePath,
|
|
150
|
+
method: 'MCP Reference Replay (1:1)',
|
|
151
|
+
actionsGenerated: actionEvents.length
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Extract role from MCP element description
|
|
157
|
+
* e.g., "Login button" -> "button", "Email textbox" -> "textbox"
|
|
158
|
+
*/
|
|
159
|
+
_extractRole(mcpElement) {
|
|
160
|
+
const lowerElement = mcpElement.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (lowerElement.includes('button')) return 'button';
|
|
163
|
+
if (lowerElement.includes('textbox')) return 'textbox';
|
|
164
|
+
if (lowerElement.includes('link')) return 'link';
|
|
165
|
+
if (lowerElement.includes('checkbox')) return 'checkbox';
|
|
166
|
+
if (lowerElement.includes('radio')) return 'radio';
|
|
167
|
+
if (lowerElement.includes('combobox')) return 'combobox';
|
|
168
|
+
if (lowerElement.includes('heading')) return 'heading';
|
|
169
|
+
|
|
170
|
+
// Default to generic
|
|
171
|
+
return 'button';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extract name from MCP element description
|
|
176
|
+
* e.g., "Login button" -> "Login", "Email textbox" -> "Email"
|
|
177
|
+
*/
|
|
178
|
+
_extractName(mcpElement) {
|
|
179
|
+
// Remove common role suffixes
|
|
180
|
+
return mcpElement
|
|
181
|
+
.replace(/\s+(button|textbox|link|checkbox|radio|combobox)$/i, '')
|
|
182
|
+
.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Escape special regex characters for safe use in regex
|
|
187
|
+
* Important for Chinese, Arabic, and special symbols
|
|
188
|
+
*/
|
|
189
|
+
_escapeRegex(text) {
|
|
190
|
+
// Escape special regex characters but preserve Unicode (Chinese, etc.)
|
|
191
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_escapeString(text) {
|
|
195
|
+
return text.replace(/'/g, "\\'").replace(/\n/g, '\\n');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { TestGenerationStrategy } from './base.js';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* StableIdStrategy - Generates tests using injected stable IDs
|
|
6
|
+
*
|
|
7
|
+
* This strategy uses deterministic IDs injected into the DOM during recording.
|
|
8
|
+
* The same IDs are re-injected during test execution for direct element lookup.
|
|
9
|
+
*
|
|
10
|
+
* Reliability: 95%+ - Works as long as DOM structure is similar between runs
|
|
11
|
+
* Bypasses all text-matching and CSS selector fragility issues.
|
|
12
|
+
*/
|
|
13
|
+
export class StableIdStrategy extends TestGenerationStrategy {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(
|
|
16
|
+
'stable-id',
|
|
17
|
+
'Stable ID Injection (Experimental)',
|
|
18
|
+
300 // Highest priority - most reliable when available
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
canGenerate(context) {
|
|
23
|
+
// Check if events have stable IDs
|
|
24
|
+
const eventsPath = context.eventsPath || `${context.sessionPath}/execute_live/events.json`;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
|
|
28
|
+
const hasStableIds = events.some(e => e.stableId || e.data?.stableId);
|
|
29
|
+
|
|
30
|
+
if (!hasStableIds) {
|
|
31
|
+
console.log('[StableIdStrategy] ❌ No stable IDs found in events');
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('[StableIdStrategy] ✅ Stable IDs available');
|
|
36
|
+
return true;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.log('[StableIdStrategy] ❌ Failed to read events:', e.message);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getName() {
|
|
44
|
+
return 'Stable ID Injection (Experimental)';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getPriority() {
|
|
48
|
+
return 300;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async generate(context) {
|
|
52
|
+
const { testFilePath, sessionPath, state } = context;
|
|
53
|
+
const eventsPath = `${sessionPath}/execute_live/events.json`;
|
|
54
|
+
const testTitle = state?.title || 'Generated Test';
|
|
55
|
+
|
|
56
|
+
console.log('[StableIdStrategy] 🎯 Generating test using stable IDs');
|
|
57
|
+
console.log(`[StableIdStrategy] events: ${eventsPath}`);
|
|
58
|
+
console.log(`[StableIdStrategy] output: ${testFilePath}`);
|
|
59
|
+
|
|
60
|
+
const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
|
|
61
|
+
const actionEvents = events.filter(e =>
|
|
62
|
+
['navigate', 'type', 'fill', 'click', 'select_option', 'select'].includes(e.type)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
let testCode = `import { test, expect } from '@playwright/test';\n`;
|
|
66
|
+
testCode += `import { StableIdRuntime } from '@zibby/core';\n\n`;
|
|
67
|
+
testCode += `test('${testTitle}', async ({ page }) => {\n`;
|
|
68
|
+
|
|
69
|
+
let skipNavigates = false;
|
|
70
|
+
let lastClickId = null;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < actionEvents.length; i++) {
|
|
73
|
+
const event = actionEvents[i];
|
|
74
|
+
const stableId = event.stableId || event.data?.stableId;
|
|
75
|
+
const mcpElement = event.data?.element || event.data?.params?.element || 'element';
|
|
76
|
+
|
|
77
|
+
// Skip duplicate consecutive clicks on same element
|
|
78
|
+
if (event.type === 'click' && stableId && stableId === lastClickId) {
|
|
79
|
+
console.log(`[StableIdStrategy] Skipping duplicate click on ${stableId}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (event.type === 'navigate') {
|
|
84
|
+
const url = event.data?.url || event.data?.params?.url;
|
|
85
|
+
if (url && !skipNavigates) {
|
|
86
|
+
testCode += ` await page.goto('${url}');\n`;
|
|
87
|
+
testCode += ` await StableIdRuntime.injectStableIds(page);\n\n`;
|
|
88
|
+
}
|
|
89
|
+
// Keep skipNavigates as-is - multiple navigates after click should all be skipped
|
|
90
|
+
} else if (event.type === 'click') {
|
|
91
|
+
skipNavigates = true;
|
|
92
|
+
lastClickId = stableId;
|
|
93
|
+
if (stableId) {
|
|
94
|
+
testCode += ` await StableIdRuntime.clickWithRetry(page, '${stableId}');\n`;
|
|
95
|
+
} else {
|
|
96
|
+
const selector = this._generateSemanticSelector(mcpElement);
|
|
97
|
+
testCode += ` await ${selector}.click();\n`;
|
|
98
|
+
testCode += ` await StableIdRuntime.afterNavigation(page);\n`;
|
|
99
|
+
}
|
|
100
|
+
} else if (event.type === 'fill' || event.type === 'type') {
|
|
101
|
+
skipNavigates = false;
|
|
102
|
+
const value = event.data?.text || event.data?.params?.text || '';
|
|
103
|
+
if (stableId) {
|
|
104
|
+
testCode += ` await StableIdRuntime.fillWithRetry(page, '${stableId}', '${this._escapeString(value)}');\n`;
|
|
105
|
+
} else {
|
|
106
|
+
testCode += ` await page.getByPlaceholder('${this._escapeString(mcpElement)}').fill('${this._escapeString(value)}');\n`;
|
|
107
|
+
}
|
|
108
|
+
} else if (event.type === 'select_option' || event.type === 'select') {
|
|
109
|
+
skipNavigates = false;
|
|
110
|
+
const values = event.data?.values || event.data?.params?.values;
|
|
111
|
+
const value = Array.isArray(values) ? values[0] : values || '';
|
|
112
|
+
if (stableId) {
|
|
113
|
+
testCode += ` await StableIdRuntime.selectWithRetry(page, '${stableId}', '${this._escapeString(value)}');\n`;
|
|
114
|
+
} else {
|
|
115
|
+
testCode += ` await page.locator('select').selectOption('${this._escapeString(value)}');\n`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
testCode += `});\n`;
|
|
121
|
+
|
|
122
|
+
writeFileSync(testFilePath, testCode);
|
|
123
|
+
|
|
124
|
+
console.log(`[StableIdStrategy] ✅ Generated test with ${actionEvents.length} actions using stable IDs`);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
testPath: testFilePath,
|
|
129
|
+
method: 'Stable ID Injection (Experimental)',
|
|
130
|
+
actionsGenerated: actionEvents.length
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_escapeString(text) {
|
|
135
|
+
return text.replace(/'/g, "\\'").replace(/\n/g, '\\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_generateSemanticSelector(elementDesc) {
|
|
139
|
+
// Parse element description like "Login button", "Email textbox", "Our people link"
|
|
140
|
+
const desc = elementDesc.toLowerCase();
|
|
141
|
+
|
|
142
|
+
// Extract role and name
|
|
143
|
+
let role = 'locator';
|
|
144
|
+
let name = elementDesc;
|
|
145
|
+
|
|
146
|
+
if (desc.includes('button')) {
|
|
147
|
+
role = 'button';
|
|
148
|
+
name = elementDesc.replace(/\s*button\s*/gi, '').trim();
|
|
149
|
+
} else if (desc.includes('link')) {
|
|
150
|
+
role = 'link';
|
|
151
|
+
name = elementDesc.replace(/\s*link\s*/gi, '').trim();
|
|
152
|
+
} else if (desc.includes('textbox')) {
|
|
153
|
+
role = 'textbox';
|
|
154
|
+
name = elementDesc.replace(/\s*textbox\s*/gi, '').trim();
|
|
155
|
+
} else if (desc.includes('checkbox')) {
|
|
156
|
+
role = 'checkbox';
|
|
157
|
+
name = elementDesc.replace(/\s*checkbox\s*/gi, '').trim();
|
|
158
|
+
} else if (desc.includes('combobox') || desc.includes('dropdown') || desc.includes('select')) {
|
|
159
|
+
role = 'combobox';
|
|
160
|
+
name = elementDesc.replace(/\s*(combobox|dropdown|select)\s*/gi, '').trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (role !== 'locator' && name) {
|
|
164
|
+
return `page.getByRole('${role}', { name: '${this._escapeString(name)}' })`;
|
|
165
|
+
}
|
|
166
|
+
// Generic fallback - try text match
|
|
167
|
+
return `page.getByText('${this._escapeString(elementDesc)}')`;
|
|
168
|
+
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StableIdRuntime - Re-inject stable IDs during test execution
|
|
3
|
+
*
|
|
4
|
+
* MUST use EXACT SAME algorithm as mcps/browser/src/stable-id-inject.js
|
|
5
|
+
* This code runs in the browser context where browser globals are available
|
|
6
|
+
*/
|
|
7
|
+
/* global document, window, URL */
|
|
8
|
+
export class StableIdRuntime {
|
|
9
|
+
static async beforeEach(page) {
|
|
10
|
+
await this.injectStableIds(page);
|
|
11
|
+
page.on('load', async () => {
|
|
12
|
+
await this.injectStableIds(page);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static async afterNavigation(page) {
|
|
17
|
+
// Wait for any pending navigation to complete
|
|
18
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
19
|
+
await this.injectStableIds(page);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async clickWithRetry(page, stableId, options = {}) {
|
|
23
|
+
const timeout = options.timeout || 10000;
|
|
24
|
+
const selector = `[data-zibby-id="${stableId}"]`;
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
|
|
27
|
+
while (Date.now() - startTime < timeout) {
|
|
28
|
+
await this.injectStableIds(page);
|
|
29
|
+
|
|
30
|
+
const el = page.locator(selector);
|
|
31
|
+
if (await el.count() > 0) {
|
|
32
|
+
try {
|
|
33
|
+
await el.click({ timeout: 2000 });
|
|
34
|
+
return;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// If element is covered, try force click
|
|
37
|
+
if (e.message.includes('intercepts pointer')) {
|
|
38
|
+
await el.click({ force: true });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await page.waitForTimeout(200);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw new Error(`Element ${selector} not found after ${timeout}ms`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static async fillWithRetry(page, stableId, value, timeout = 10000) {
|
|
51
|
+
const selector = `[data-zibby-id="${stableId}"]`;
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
|
|
54
|
+
while (Date.now() - startTime < timeout) {
|
|
55
|
+
await this.injectStableIds(page);
|
|
56
|
+
|
|
57
|
+
const el = page.locator(selector);
|
|
58
|
+
if (await el.count() > 0) {
|
|
59
|
+
await el.fill(value);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await page.waitForTimeout(200);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Element ${selector} not found after ${timeout}ms`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static async selectWithRetry(page, stableId, value, timeout = 10000) {
|
|
70
|
+
const selector = `[data-zibby-id="${stableId}"]`;
|
|
71
|
+
const startTime = Date.now();
|
|
72
|
+
|
|
73
|
+
while (Date.now() - startTime < timeout) {
|
|
74
|
+
await this.injectStableIds(page);
|
|
75
|
+
|
|
76
|
+
const el = page.locator(selector);
|
|
77
|
+
if (await el.count() > 0) {
|
|
78
|
+
await el.selectOption(value);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await page.waitForTimeout(200);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(`Element ${selector} not found after ${timeout}ms`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static async injectStableIds(page) {
|
|
89
|
+
try {
|
|
90
|
+
await page.evaluate(() => {
|
|
91
|
+
// EXACT SAME algorithm as stable-id-inject.js
|
|
92
|
+
|
|
93
|
+
function getSemanticLabel(el) {
|
|
94
|
+
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label').trim();
|
|
95
|
+
|
|
96
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
97
|
+
if (labelledBy) {
|
|
98
|
+
const labelEl = document.getElementById(labelledBy);
|
|
99
|
+
if (labelEl) return labelEl.textContent.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (el.id) {
|
|
103
|
+
const label = document.querySelector(`label[for="${el.id}"]`);
|
|
104
|
+
if (label) return label.textContent.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parentLabel = el.closest('label');
|
|
108
|
+
if (parentLabel) {
|
|
109
|
+
const clone = parentLabel.cloneNode(true);
|
|
110
|
+
clone.querySelectorAll('input, select, textarea').forEach(e => e.remove());
|
|
111
|
+
const text = clone.textContent.trim();
|
|
112
|
+
if (text) return text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (el.placeholder) return el.placeholder;
|
|
116
|
+
|
|
117
|
+
const tag = el.tagName.toLowerCase();
|
|
118
|
+
if (tag === 'button' || tag === 'a' || el.getAttribute('role') === 'button') {
|
|
119
|
+
return (el.textContent || '').trim().slice(0, 50);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (el.title) return el.title;
|
|
123
|
+
|
|
124
|
+
if (tag === 'input' && (el.type === 'submit' || el.type === 'button')) {
|
|
125
|
+
return el.value || '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getSemanticContext(el) {
|
|
132
|
+
const context = [];
|
|
133
|
+
|
|
134
|
+
const form = el.closest('form');
|
|
135
|
+
if (form) {
|
|
136
|
+
if (form.id) context.push(`form#${ form.id}`);
|
|
137
|
+
else if (form.name) context.push(`form[name=${ form.name }]`);
|
|
138
|
+
else if (form.action) {
|
|
139
|
+
try {
|
|
140
|
+
const action = new URL(form.action, window.location.origin).pathname;
|
|
141
|
+
context.push(`form[action=${ action }]`);
|
|
142
|
+
} catch {
|
|
143
|
+
context.push(`form[action=${ form.getAttribute('action') }]`);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
const forms = document.querySelectorAll('form');
|
|
147
|
+
const index = Array.from(forms).indexOf(form);
|
|
148
|
+
context.push(`form:nth(${ index })`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const landmark = el.closest('header, nav, main, footer, aside, [role="banner"], [role="navigation"], [role="main"], [role="contentinfo"]');
|
|
153
|
+
if (landmark) {
|
|
154
|
+
const tag = landmark.tagName.toLowerCase();
|
|
155
|
+
const role = landmark.getAttribute('role');
|
|
156
|
+
context.push(role || tag);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const section = el.closest('section, article, [role="region"]');
|
|
160
|
+
if (section) {
|
|
161
|
+
const heading = section.querySelector('h1, h2, h3, h4, h5, h6');
|
|
162
|
+
if (heading) {
|
|
163
|
+
context.push(`section:${ heading.textContent.trim().slice(0, 30)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fieldset = el.closest('fieldset');
|
|
168
|
+
if (fieldset) {
|
|
169
|
+
const legend = fieldset.querySelector('legend');
|
|
170
|
+
if (legend) {
|
|
171
|
+
context.push(`fieldset:${ legend.textContent.trim()}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const dialog = el.closest('dialog, [role="dialog"], [role="alertdialog"]');
|
|
176
|
+
if (dialog) {
|
|
177
|
+
const title = dialog.querySelector('[role="heading"], h1, h2, h3');
|
|
178
|
+
if (title) context.push(`dialog:${ title.textContent.trim().slice(0, 30)}`);
|
|
179
|
+
else context.push('dialog');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return context.join('/');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function computeStableId(el) {
|
|
186
|
+
const tag = el.tagName.toLowerCase();
|
|
187
|
+
const id = el.id || '';
|
|
188
|
+
const name = el.name || '';
|
|
189
|
+
const type = el.type || '';
|
|
190
|
+
const role = el.getAttribute('role') || '';
|
|
191
|
+
|
|
192
|
+
let href = '';
|
|
193
|
+
if (el.href) {
|
|
194
|
+
try {
|
|
195
|
+
href = new URL(el.href, window.location.origin).pathname.slice(0, 50);
|
|
196
|
+
} catch {
|
|
197
|
+
href = el.getAttribute('href')?.slice(0, 50) || '';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const label = getSemanticLabel(el).slice(0, 50).replace(/\s+/g, ' ');
|
|
202
|
+
const context = getSemanticContext(el);
|
|
203
|
+
|
|
204
|
+
const sig = [tag, id, name, type, role, href, label, context].join('|');
|
|
205
|
+
|
|
206
|
+
let hash = 5381;
|
|
207
|
+
for (let i = 0; i < sig.length; i++) {
|
|
208
|
+
hash = ((hash << 5) + hash) ^ sig.charCodeAt(i);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return `zibby-${ (hash >>> 0).toString(36)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const selectors = [
|
|
215
|
+
'button', 'a', 'input', 'select', 'textarea',
|
|
216
|
+
'label[for]',
|
|
217
|
+
'[role="button"]', '[role="link"]', '[role="textbox"]',
|
|
218
|
+
'[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
|
|
219
|
+
'[role="menuitem"]', '[role="tab"]', '[role="option"]',
|
|
220
|
+
'[role="switch"]', '[role="slider"]',
|
|
221
|
+
'[onclick]', '[data-action]'
|
|
222
|
+
].join(', ');
|
|
223
|
+
|
|
224
|
+
const idCounts = new Map();
|
|
225
|
+
let count = 0;
|
|
226
|
+
|
|
227
|
+
document.querySelectorAll(selectors).forEach((el) => {
|
|
228
|
+
const style = window.getComputedStyle(el);
|
|
229
|
+
if (style.display === 'none' || style.visibility === 'hidden') return;
|
|
230
|
+
|
|
231
|
+
let stableId = computeStableId(el);
|
|
232
|
+
|
|
233
|
+
const baseId = stableId;
|
|
234
|
+
const existing = idCounts.get(baseId) || 0;
|
|
235
|
+
if (existing > 0) stableId = `${baseId }-${ existing}`;
|
|
236
|
+
idCounts.set(baseId, existing + 1);
|
|
237
|
+
|
|
238
|
+
el.setAttribute('data-zibby-id', stableId);
|
|
239
|
+
count++;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
console.log(`[Zibby] Injected ${count} stable IDs`);
|
|
243
|
+
});
|
|
244
|
+
} catch (_e) {
|
|
245
|
+
// Navigation destroyed context - this is OK, IDs will be injected on next retry
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for test verification strategies
|
|
3
|
+
* Defines the interface that all verification strategies must implement
|
|
4
|
+
*/
|
|
5
|
+
export class TestVerificationStrategy {
|
|
6
|
+
/**
|
|
7
|
+
* Verify a generated test
|
|
8
|
+
* @param {Object} context - Verification context
|
|
9
|
+
* @param {string} context.testFilePath - Path to test file
|
|
10
|
+
* @param {string} context.cwd - Current working directory
|
|
11
|
+
* @param {number} context.timeout - Test timeout in ms
|
|
12
|
+
* @returns {Promise<Object>} - { success: boolean, passed: number, failed: number, errorDetails?: string }
|
|
13
|
+
*/
|
|
14
|
+
async verify(_context) {
|
|
15
|
+
throw new Error('TestVerificationStrategy.verify() must be implemented');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if this strategy can verify the test
|
|
20
|
+
* @param {Object} context - Same context as verify()
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
canVerify(_context) {
|
|
24
|
+
throw new Error('TestVerificationStrategy.canVerify() must be implemented');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get strategy name for logging
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
getName() {
|
|
32
|
+
throw new Error('TestVerificationStrategy.getName() must be implemented');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get strategy priority (higher = try first)
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
getPriority() {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default TestVerificationStrategy;
|