@matware/e2e-runner 1.1.1 → 1.2.1
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/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +475 -307
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +194 -6
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +10 -2
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +273 -18
- package/src/ai-generate.js +87 -7
- package/src/config.js +28 -0
- package/src/dashboard.js +156 -6
- package/src/db.js +207 -13
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +448 -18
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +35 -2
- package/src/runner.js +120 -46
- package/src/verify.js +5 -3
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +964 -378
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Resolver for E2E Runner
|
|
3
|
+
*
|
|
4
|
+
* Enables reusable action sequences via $use references.
|
|
5
|
+
* Modules are JSON files in the modules directory with a $module key.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Parameter substitution: {{param}} and {{#param}}...{{/param}} conditionals
|
|
9
|
+
* - Module composition: modules can $use other modules
|
|
10
|
+
* - Cycle detection: prevents infinite recursion
|
|
11
|
+
* - Fail-fast: required params without values throw immediately
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Loads all module definitions from a directory.
|
|
19
|
+
* @param {string} modulesDir - Absolute path to modules directory
|
|
20
|
+
* @returns {Map<string, object>} Map of module name -> definition
|
|
21
|
+
*/
|
|
22
|
+
export function loadModuleRegistry(modulesDir) {
|
|
23
|
+
const registry = new Map();
|
|
24
|
+
|
|
25
|
+
if (!modulesDir || !fs.existsSync(modulesDir)) {
|
|
26
|
+
return registry;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json'));
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const filePath = path.join(modulesDir, file);
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
34
|
+
|
|
35
|
+
if (!data.$module) {
|
|
36
|
+
continue; // Not a module definition
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (registry.has(data.$module)) {
|
|
40
|
+
throw new Error(`Duplicate module name "${data.$module}" in ${file}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
registry.set(data.$module, data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return registry;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replaces {{param}} placeholders and {{#param}}...{{/param}} conditionals in a string.
|
|
51
|
+
* @param {string} str - Template string
|
|
52
|
+
* @param {object} params - Parameter values
|
|
53
|
+
* @param {object} paramDefs - Parameter definitions from the module (for defaults)
|
|
54
|
+
* @returns {string} Resolved string
|
|
55
|
+
*/
|
|
56
|
+
function substituteParams(str, params, paramDefs) {
|
|
57
|
+
if (typeof str !== 'string') return str;
|
|
58
|
+
|
|
59
|
+
// Build effective params: defaults + provided
|
|
60
|
+
const effective = {};
|
|
61
|
+
if (paramDefs) {
|
|
62
|
+
for (const [key, def] of Object.entries(paramDefs)) {
|
|
63
|
+
if (def.default !== undefined) {
|
|
64
|
+
effective[key] = def.default;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
Object.assign(effective, params);
|
|
69
|
+
|
|
70
|
+
// Process conditional blocks: {{#key}}content{{/key}}
|
|
71
|
+
let result = str.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, content) => {
|
|
72
|
+
const val = effective[key];
|
|
73
|
+
if (val !== undefined && val !== '' && val !== null && val !== false) {
|
|
74
|
+
// Recursively substitute inside the block
|
|
75
|
+
return substituteParams(content, params, paramDefs);
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Process simple substitutions: {{key}}
|
|
81
|
+
result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
82
|
+
if (key in effective) return String(effective[key]);
|
|
83
|
+
return match; // Leave unresolved (will be caught by validation)
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Applies parameter substitution to all string fields of an action.
|
|
91
|
+
* @param {object} action - Action object
|
|
92
|
+
* @param {object} params - Parameter values
|
|
93
|
+
* @param {object} paramDefs - Parameter definitions
|
|
94
|
+
* @returns {object} New action with substituted values
|
|
95
|
+
*/
|
|
96
|
+
function substituteActionParams(action, params, paramDefs, moduleName) {
|
|
97
|
+
const result = {};
|
|
98
|
+
for (const [key, value] of Object.entries(action)) {
|
|
99
|
+
if (typeof value === 'string') {
|
|
100
|
+
result[key] = substituteParams(value, params, paramDefs);
|
|
101
|
+
} else {
|
|
102
|
+
result[key] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check for unresolved placeholders
|
|
107
|
+
for (const [key, value] of Object.entries(result)) {
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
const unresolved = value.match(/\{\{(\w+)\}\}/g);
|
|
110
|
+
if (unresolved) {
|
|
111
|
+
const paramNames = unresolved.map(m => m.slice(2, -2));
|
|
112
|
+
throw new Error(`Module "${moduleName || 'unknown'}": unresolved parameter(s) ${paramNames.join(', ')} in "${key}". Provide them via params or define defaults.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validates that all required parameters have values.
|
|
122
|
+
* @param {object} paramDefs - Parameter definitions from module
|
|
123
|
+
* @param {object} params - Provided parameter values
|
|
124
|
+
* @param {string} moduleName - Module name for error messages
|
|
125
|
+
*/
|
|
126
|
+
function validateParams(paramDefs, params, moduleName) {
|
|
127
|
+
if (!paramDefs) return;
|
|
128
|
+
|
|
129
|
+
for (const [key, def] of Object.entries(paramDefs)) {
|
|
130
|
+
if (def.required && !(key in params) && def.default === undefined) {
|
|
131
|
+
throw new Error(`Module "${moduleName}": missing required parameter "${key}"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolves an array of actions, expanding $use references recursively.
|
|
138
|
+
* @param {Array} actions - Array of actions (may contain $use references)
|
|
139
|
+
* @param {Map} registry - Module registry
|
|
140
|
+
* @param {object} contextParams - Parameters from parent scope
|
|
141
|
+
* @param {Set} visited - Set of module names in the current resolution chain (cycle detection)
|
|
142
|
+
* @returns {Array} Flattened array of concrete actions
|
|
143
|
+
*/
|
|
144
|
+
function resolveActions(actions, registry, contextParams = {}, visited = new Set()) {
|
|
145
|
+
const resolved = [];
|
|
146
|
+
|
|
147
|
+
for (const action of actions) {
|
|
148
|
+
if (action.$use) {
|
|
149
|
+
const moduleName = action.$use;
|
|
150
|
+
|
|
151
|
+
// Cycle detection
|
|
152
|
+
if (visited.has(moduleName)) {
|
|
153
|
+
throw new Error(`Circular module dependency detected: ${[...visited, moduleName].join(' -> ')}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const moduleDef = registry.get(moduleName);
|
|
157
|
+
if (!moduleDef) {
|
|
158
|
+
throw new Error(`Module not found: "${moduleName}". Available: ${[...registry.keys()].join(', ') || 'none'}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Merge params: context params as fallback, then explicit params
|
|
162
|
+
const mergedParams = { ...contextParams, ...action.params };
|
|
163
|
+
validateParams(moduleDef.params, mergedParams, moduleName);
|
|
164
|
+
|
|
165
|
+
// Recursively resolve the module's actions
|
|
166
|
+
const newVisited = new Set(visited);
|
|
167
|
+
newVisited.add(moduleName);
|
|
168
|
+
|
|
169
|
+
const moduleActions = moduleDef.actions || [];
|
|
170
|
+
const substituted = moduleActions.map(a => {
|
|
171
|
+
if (a.$use) {
|
|
172
|
+
// Nested $use — pass through for recursive resolution
|
|
173
|
+
return { ...a, params: { ...mergedParams, ...a.params } };
|
|
174
|
+
}
|
|
175
|
+
return substituteActionParams(a, mergedParams, moduleDef.params, moduleName);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const expandedActions = resolveActions(substituted, registry, mergedParams, newVisited);
|
|
179
|
+
resolved.push(...expandedActions);
|
|
180
|
+
} else {
|
|
181
|
+
resolved.push(action);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolves all $use references in a test data structure.
|
|
190
|
+
* @param {object} data - { tests, hooks } as returned by normalizeTestData
|
|
191
|
+
* @param {string} modulesDir - Absolute path to modules directory
|
|
192
|
+
* @returns {object} New { tests, hooks } with all $use expanded
|
|
193
|
+
*/
|
|
194
|
+
export function resolveTestData(data, modulesDir) {
|
|
195
|
+
if (!modulesDir || !fs.existsSync(modulesDir)) {
|
|
196
|
+
return data; // No modules directory — pass through unchanged
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const registry = loadModuleRegistry(modulesDir);
|
|
200
|
+
if (registry.size === 0) {
|
|
201
|
+
return data; // No modules defined — pass through
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check if there are any $use references to resolve
|
|
205
|
+
const hasUseRef = (actions) => actions?.some(a => a.$use);
|
|
206
|
+
const hooksNeedResolving = Object.values(data.hooks || {}).some(hasUseRef);
|
|
207
|
+
const testsNeedResolving = data.tests?.some(t => hasUseRef(t.actions));
|
|
208
|
+
|
|
209
|
+
if (!hooksNeedResolving && !testsNeedResolving) {
|
|
210
|
+
return data; // No $use references — pass through
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Resolve hooks
|
|
214
|
+
const resolvedHooks = {};
|
|
215
|
+
for (const [hookName, actions] of Object.entries(data.hooks || {})) {
|
|
216
|
+
if (Array.isArray(actions)) {
|
|
217
|
+
resolvedHooks[hookName] = resolveActions(actions, registry);
|
|
218
|
+
} else {
|
|
219
|
+
resolvedHooks[hookName] = actions;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Resolve test actions
|
|
224
|
+
const resolvedTests = (data.tests || []).map(test => {
|
|
225
|
+
if (!hasUseRef(test.actions)) return test;
|
|
226
|
+
return {
|
|
227
|
+
...test,
|
|
228
|
+
actions: resolveActions(test.actions, registry),
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { tests: resolvedTests, hooks: resolvedHooks };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Lists available modules with their metadata.
|
|
237
|
+
* @param {string} modulesDir - Absolute path to modules directory
|
|
238
|
+
* @returns {Array<{name, description, params, file}>} Module metadata
|
|
239
|
+
*/
|
|
240
|
+
export function listModules(modulesDir) {
|
|
241
|
+
if (!modulesDir || !fs.existsSync(modulesDir)) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json'));
|
|
246
|
+
const modules = [];
|
|
247
|
+
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
const filePath = path.join(modulesDir, file);
|
|
250
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
251
|
+
|
|
252
|
+
if (!data.$module) continue;
|
|
253
|
+
|
|
254
|
+
const paramList = data.params
|
|
255
|
+
? Object.entries(data.params).map(([name, def]) => ({
|
|
256
|
+
name,
|
|
257
|
+
required: !!def.required,
|
|
258
|
+
default: def.default,
|
|
259
|
+
description: def.description,
|
|
260
|
+
}))
|
|
261
|
+
: [];
|
|
262
|
+
|
|
263
|
+
modules.push({
|
|
264
|
+
name: data.$module,
|
|
265
|
+
description: data.description || '',
|
|
266
|
+
file,
|
|
267
|
+
actionCount: (data.actions || []).length,
|
|
268
|
+
params: paramList,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return modules;
|
|
273
|
+
}
|
package/src/narrate.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Narrator
|
|
3
|
+
*
|
|
4
|
+
* Converts raw action + result data into human-readable narrative strings.
|
|
5
|
+
* Each test step becomes a sentence describing what happened.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates a human-readable narrative for a single action result.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} action — The original action (type, selector, value, text)
|
|
12
|
+
* @param {object} result — The action result (success, duration, error, result)
|
|
13
|
+
* @returns {string} A narrative string describing what happened
|
|
14
|
+
*/
|
|
15
|
+
export function narrateAction(action, result) {
|
|
16
|
+
const { type, selector, value, text } = action;
|
|
17
|
+
const { success, duration, error } = result;
|
|
18
|
+
const time = duration != null ? ` (${duration}ms)` : '';
|
|
19
|
+
|
|
20
|
+
if (!success) {
|
|
21
|
+
return `FAIL: ${describeIntent(action)} — ${error}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
switch (type) {
|
|
25
|
+
case 'goto':
|
|
26
|
+
return `Navigated to ${value}${time}`;
|
|
27
|
+
|
|
28
|
+
case 'click':
|
|
29
|
+
if (selector) return `Clicked on "${selector}"${time}`;
|
|
30
|
+
if (text) return `Clicked on element with text "${text}"${time}`;
|
|
31
|
+
return `Clicked${time}`;
|
|
32
|
+
|
|
33
|
+
case 'type':
|
|
34
|
+
case 'fill': {
|
|
35
|
+
const masked = isSensitive(selector) ? '***' : value;
|
|
36
|
+
return `Typed "${masked}" into "${selector}"${time}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'wait':
|
|
40
|
+
if (selector) return `Waited for "${selector}" to appear${time}`;
|
|
41
|
+
if (text) return `Waited for text "${text}" to appear${time}`;
|
|
42
|
+
return `Waited ${value}ms`;
|
|
43
|
+
|
|
44
|
+
case 'screenshot':
|
|
45
|
+
if (result.result?.screenshot) {
|
|
46
|
+
return `Captured screenshot: ${result.result.screenshot}`;
|
|
47
|
+
}
|
|
48
|
+
return `Captured screenshot${value ? `: ${value}` : ''}`;
|
|
49
|
+
|
|
50
|
+
case 'assert_text':
|
|
51
|
+
return `Verified text "${text}" is present on page${time}`;
|
|
52
|
+
|
|
53
|
+
case 'assert_url':
|
|
54
|
+
return `Verified URL contains "${value}"${time}`;
|
|
55
|
+
|
|
56
|
+
case 'assert_visible':
|
|
57
|
+
return `Verified "${selector}" is visible${time}`;
|
|
58
|
+
|
|
59
|
+
case 'assert_count':
|
|
60
|
+
return `Verified "${selector}" has ${value} element(s)${time}`;
|
|
61
|
+
|
|
62
|
+
case 'assert_element_text':
|
|
63
|
+
return `Verified "${selector}" contains text "${text}"${time}`;
|
|
64
|
+
|
|
65
|
+
case 'assert_attribute':
|
|
66
|
+
return `Verified attribute on "${selector}": ${value}${time}`;
|
|
67
|
+
|
|
68
|
+
case 'assert_class':
|
|
69
|
+
return `Verified "${selector}" has class "${value}"${time}`;
|
|
70
|
+
|
|
71
|
+
case 'assert_not_visible':
|
|
72
|
+
return `Verified "${selector}" is not visible${time}`;
|
|
73
|
+
|
|
74
|
+
case 'assert_input_value':
|
|
75
|
+
return `Verified input "${selector}" has value "${value}"${time}`;
|
|
76
|
+
|
|
77
|
+
case 'assert_matches':
|
|
78
|
+
return `Verified "${selector}" matches pattern /${value}/${time}`;
|
|
79
|
+
|
|
80
|
+
case 'get_text': {
|
|
81
|
+
const extractedText = result.result?.value;
|
|
82
|
+
if (extractedText) {
|
|
83
|
+
const shortText = extractedText.length > 50 ? extractedText.slice(0, 47) + '...' : extractedText;
|
|
84
|
+
return `Read text from "${selector}": "${shortText}"${time}`;
|
|
85
|
+
}
|
|
86
|
+
return `Read text from "${selector}"${time}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'assert_no_network_errors':
|
|
90
|
+
return `Verified no network errors occurred${time}`;
|
|
91
|
+
|
|
92
|
+
case 'select':
|
|
93
|
+
return `Selected option "${value}" in "${selector}"${time}`;
|
|
94
|
+
|
|
95
|
+
case 'clear':
|
|
96
|
+
return `Cleared input "${selector}"${time}`;
|
|
97
|
+
|
|
98
|
+
case 'press':
|
|
99
|
+
return `Pressed key "${value}"${time}`;
|
|
100
|
+
|
|
101
|
+
case 'scroll':
|
|
102
|
+
if (selector) return `Scrolled to "${selector}"${time}`;
|
|
103
|
+
return `Scrolled down ${value || 300}px${time}`;
|
|
104
|
+
|
|
105
|
+
case 'hover':
|
|
106
|
+
return `Hovered over "${selector}"${time}`;
|
|
107
|
+
|
|
108
|
+
case 'clear_cookies':
|
|
109
|
+
return `Cleared cookies and storage${value ? ` for ${value}` : ''}${time}`;
|
|
110
|
+
|
|
111
|
+
case 'navigate':
|
|
112
|
+
return `Navigated (SPA) to ${value}${time}`;
|
|
113
|
+
|
|
114
|
+
case 'type_react': {
|
|
115
|
+
const masked = isSensitive(selector) ? '***' : value;
|
|
116
|
+
return `Typed "${masked}" into React input "${selector}"${time}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'click_regex':
|
|
120
|
+
if (selector) return `Clicked ${value === 'last' ? 'last ' : ''}element matching /${text}/i in "${selector}"${time}`;
|
|
121
|
+
return `Clicked ${value === 'last' ? 'last ' : ''}element matching /${text}/i${time}`;
|
|
122
|
+
|
|
123
|
+
case 'click_option':
|
|
124
|
+
return `Clicked dropdown option "${text}"${time}`;
|
|
125
|
+
|
|
126
|
+
case 'focus_autocomplete':
|
|
127
|
+
return `Focused autocomplete labeled "${text}"${time}`;
|
|
128
|
+
|
|
129
|
+
case 'click_chip':
|
|
130
|
+
return `Clicked chip "${text}"${time}`;
|
|
131
|
+
|
|
132
|
+
case 'evaluate': {
|
|
133
|
+
const snippet = value.length > 80 ? value.slice(0, 77) + '...' : value;
|
|
134
|
+
const evalResult = result.result?.value;
|
|
135
|
+
if (evalResult !== undefined && evalResult !== null) {
|
|
136
|
+
const valStr = typeof evalResult === 'string' ? evalResult : JSON.stringify(evalResult);
|
|
137
|
+
const shortVal = valStr.length > 50 ? valStr.slice(0, 47) + '...' : valStr;
|
|
138
|
+
return `Executed JS: ${snippet} → ${shortVal}${time}`;
|
|
139
|
+
}
|
|
140
|
+
return `Executed JS: ${snippet}${time}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
default:
|
|
144
|
+
return `Unknown action "${type}"${time}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Describes the intent of an action (used in failure messages).
|
|
150
|
+
*/
|
|
151
|
+
function describeIntent(action) {
|
|
152
|
+
const { type, selector, value, text } = action;
|
|
153
|
+
|
|
154
|
+
switch (type) {
|
|
155
|
+
case 'goto': return `Navigate to ${value}`;
|
|
156
|
+
case 'click': return selector ? `Click on "${selector}"` : `Click on text "${text}"`;
|
|
157
|
+
case 'type':
|
|
158
|
+
case 'fill': return `Type into "${selector}"`;
|
|
159
|
+
case 'wait':
|
|
160
|
+
if (selector) return `Wait for "${selector}"`;
|
|
161
|
+
if (text) return `Wait for text "${text}"`;
|
|
162
|
+
return `Wait ${value}ms`;
|
|
163
|
+
case 'screenshot': return 'Capture screenshot';
|
|
164
|
+
case 'assert_text': return `Assert text "${text}" present`;
|
|
165
|
+
case 'assert_url': return `Assert URL contains "${value}"`;
|
|
166
|
+
case 'assert_visible': return `Assert "${selector}" visible`;
|
|
167
|
+
case 'assert_count': return `Assert "${selector}" count = ${value}`;
|
|
168
|
+
case 'assert_element_text': return `Assert "${selector}" contains "${text}"`;
|
|
169
|
+
case 'assert_attribute': return `Assert attribute on "${selector}": ${value}`;
|
|
170
|
+
case 'assert_class': return `Assert "${selector}" has class "${value}"`;
|
|
171
|
+
case 'assert_not_visible': return `Assert "${selector}" not visible`;
|
|
172
|
+
case 'assert_input_value': return `Assert input "${selector}" value "${value}"`;
|
|
173
|
+
case 'assert_matches': return `Assert "${selector}" matches /${value}/`;
|
|
174
|
+
case 'get_text': return `Get text from "${selector}"`;
|
|
175
|
+
case 'assert_no_network_errors': return 'Assert no network errors';
|
|
176
|
+
case 'select': return `Select "${value}" in "${selector}"`;
|
|
177
|
+
case 'clear': return `Clear "${selector}"`;
|
|
178
|
+
case 'press': return `Press key "${value}"`;
|
|
179
|
+
case 'scroll': return selector ? `Scroll to "${selector}"` : `Scroll down`;
|
|
180
|
+
case 'hover': return `Hover over "${selector}"`;
|
|
181
|
+
case 'clear_cookies': return 'Clear cookies and storage';
|
|
182
|
+
case 'navigate': return `Navigate to ${value}`;
|
|
183
|
+
case 'type_react': return `Type into React input "${selector}"`;
|
|
184
|
+
case 'click_regex': return `Click element matching /${text}/i`;
|
|
185
|
+
case 'click_option': return `Click option "${text}"`;
|
|
186
|
+
case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
|
|
187
|
+
case 'click_chip': return `Click chip "${text}"`;
|
|
188
|
+
case 'evaluate': return 'Execute JS';
|
|
189
|
+
default: return `Action "${type}"`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Checks if a selector likely refers to a sensitive field (password, token, etc.)
|
|
195
|
+
*/
|
|
196
|
+
function isSensitive(selector) {
|
|
197
|
+
if (!selector) return false;
|
|
198
|
+
return /password|secret|token|pin|ssn|cvv/i.test(selector);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds a full test narrative from the result's actions array.
|
|
203
|
+
* Returns a numbered step-by-step summary.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} testResult — A single test result with actions[], name, success, error
|
|
206
|
+
* @returns {string[]} Array of narrative lines
|
|
207
|
+
*/
|
|
208
|
+
export function narrateTest(testResult) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < testResult.actions.length; i++) {
|
|
212
|
+
const action = testResult.actions[i];
|
|
213
|
+
const narrative = action.narrative || narrateAction(action, action);
|
|
214
|
+
lines.push(`${i + 1}. ${narrative}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!testResult.success && testResult.error) {
|
|
218
|
+
const failedAt = testResult.actions.findIndex(a => !a.success);
|
|
219
|
+
if (failedAt === -1) {
|
|
220
|
+
lines.push(`✗ Test failed: ${testResult.error}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j Docker container lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Follows the same pattern as src/pool.js for Chrome pool management.
|
|
5
|
+
* Uses docker compose to spin up/stop a Neo4j container.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { log, colors as C } from './logger.js';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose-neo4j.yml');
|
|
17
|
+
const NEO4J_DIR = '.e2e-neo4j';
|
|
18
|
+
|
|
19
|
+
function getComposeDir(cwd) {
|
|
20
|
+
return path.join(cwd, NEO4J_DIR);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getComposePath(cwd) {
|
|
24
|
+
return path.join(getComposeDir(cwd), 'docker-compose.yml');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureComposeFile(config, cwd) {
|
|
28
|
+
const composeDir = getComposeDir(cwd);
|
|
29
|
+
const composePath = getComposePath(cwd);
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(composeDir)) {
|
|
32
|
+
fs.mkdirSync(composeDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const template = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
|
|
36
|
+
const content = template
|
|
37
|
+
.replace(/\$\{BOLT_PORT\}/g, String(config.neo4jBoltPort || 7687))
|
|
38
|
+
.replace(/\$\{HTTP_PORT\}/g, String(config.neo4jHttpPort || 7474))
|
|
39
|
+
.replace(/\$\{NEO4J_PASSWORD\}/g, config.neo4jPassword || 'e2erunner');
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(composePath, content);
|
|
42
|
+
return composePath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Start the Neo4j container. */
|
|
46
|
+
export function startNeo4j(config, cwd = null) {
|
|
47
|
+
cwd = cwd || config._cwd || process.cwd();
|
|
48
|
+
const composePath = ensureComposeFile(config, cwd);
|
|
49
|
+
const composeDir = getComposeDir(cwd);
|
|
50
|
+
|
|
51
|
+
log('🗄️', `Starting Neo4j on bolt://localhost:${config.neo4jBoltPort || 7687}...`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], {
|
|
55
|
+
cwd: composeDir,
|
|
56
|
+
stdio: 'inherit',
|
|
57
|
+
});
|
|
58
|
+
log('✅', `Neo4j started. Browser: ${C.cyan}http://localhost:${config.neo4jHttpPort || 7474}${C.reset}`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log('❌', `Failed to start Neo4j: ${err.message}`);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stop the Neo4j container. */
|
|
66
|
+
export function stopNeo4j(config, cwd = null) {
|
|
67
|
+
cwd = cwd || config._cwd || process.cwd();
|
|
68
|
+
const composePath = getComposePath(cwd);
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(composePath)) {
|
|
71
|
+
log('⚠️', 'No Neo4j compose file found. Is Neo4j running?');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log('🗄️', 'Stopping Neo4j...');
|
|
76
|
+
try {
|
|
77
|
+
execFileSync('docker', ['compose', '-f', composePath, 'down'], {
|
|
78
|
+
cwd: getComposeDir(cwd),
|
|
79
|
+
stdio: 'inherit',
|
|
80
|
+
});
|
|
81
|
+
log('✅', 'Neo4j stopped');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log('❌', `Failed to stop Neo4j: ${err.message}`);
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get Neo4j container status. */
|
|
89
|
+
export function getNeo4jStatus(config, cwd = null) {
|
|
90
|
+
cwd = cwd || config._cwd || process.cwd();
|
|
91
|
+
// Ensure compose file exists from template (same as start does)
|
|
92
|
+
const composePath = ensureComposeFile(config, cwd);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const output = execFileSync('docker', ['compose', '-f', composePath, 'ps', '--format', 'json'], {
|
|
96
|
+
cwd: getComposeDir(cwd),
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// docker compose ps --format json outputs one JSON object per line
|
|
102
|
+
const lines = output.trim().split('\n').filter(Boolean);
|
|
103
|
+
const containers = lines.map(line => {
|
|
104
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
105
|
+
}).filter(Boolean);
|
|
106
|
+
|
|
107
|
+
const neo4jContainer = containers.find(c => c.Service === 'neo4j' || c.Name?.includes('neo4j'));
|
|
108
|
+
|
|
109
|
+
if (neo4jContainer) {
|
|
110
|
+
const isRunning = neo4jContainer.State === 'running';
|
|
111
|
+
return {
|
|
112
|
+
running: isRunning,
|
|
113
|
+
state: neo4jContainer.State,
|
|
114
|
+
boltPort: config.neo4jBoltPort || 7687,
|
|
115
|
+
httpPort: config.neo4jHttpPort || 7474,
|
|
116
|
+
boltUrl: config.neo4jBoltUrl || `bolt://localhost:${config.neo4jBoltPort || 7687}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { running: false, error: 'Container not found' };
|
|
121
|
+
} catch {
|
|
122
|
+
return { running: false, error: 'Docker compose not available or container not running' };
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -6,6 +6,9 @@ import fs from 'fs';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { colors as C } from './logger.js';
|
|
8
8
|
import { ensureProject, saveRun as saveRunToDb } from './db.js';
|
|
9
|
+
import { narrateTest } from './narrate.js';
|
|
10
|
+
import { learnFromRun } from './learner.js';
|
|
11
|
+
import { generateLearningsMarkdown } from './learner-markdown.js';
|
|
9
12
|
|
|
10
13
|
function escapeXml(str) {
|
|
11
14
|
return String(str)
|
|
@@ -149,15 +152,34 @@ export function loadHistoryRun(screenshotsDir, runId) {
|
|
|
149
152
|
/** Persists a run to both filesystem history and SQLite (never throws). */
|
|
150
153
|
export function persistRun(report, config, suiteName) {
|
|
151
154
|
const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
|
|
155
|
+
let runDbId = null;
|
|
152
156
|
|
|
153
157
|
try {
|
|
154
158
|
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
155
|
-
saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
|
|
159
|
+
runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
|
|
160
|
+
|
|
161
|
+
// Fire-and-forget: learn from this run (never blocks or crashes the runner)
|
|
162
|
+
if (config.learningsEnabled !== false) {
|
|
163
|
+
try {
|
|
164
|
+
learnFromRun(projectId, runDbId, report, config, suiteName);
|
|
165
|
+
} catch (learnErr) {
|
|
166
|
+
process.stderr.write(`[e2e-runner] Learning write failed: ${learnErr.message}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Generate learnings markdown if enabled
|
|
170
|
+
if (config.learningsMarkdown !== false) {
|
|
171
|
+
try {
|
|
172
|
+
generateLearningsMarkdown(projectId, config);
|
|
173
|
+
} catch (mdErr) {
|
|
174
|
+
process.stderr.write(`[e2e-runner] Learnings markdown failed: ${mdErr.message}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
156
178
|
} catch (err) {
|
|
157
179
|
process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
|
|
158
180
|
}
|
|
159
181
|
|
|
160
|
-
return runId;
|
|
182
|
+
return { runId, runDbId };
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
/** Prints a formatted report summary to the console */
|
|
@@ -222,6 +244,17 @@ export function printReport(report, screenshotsDir) {
|
|
|
222
244
|
});
|
|
223
245
|
}
|
|
224
246
|
|
|
247
|
+
// Print step-by-step narrative for each test
|
|
248
|
+
console.log(`\n${C.bold}NARRATIVE:${C.reset}`);
|
|
249
|
+
for (const result of report.results) {
|
|
250
|
+
const icon = result.success ? `${C.green}✓${C.reset}` : `${C.red}✗${C.reset}`;
|
|
251
|
+
console.log(` ${icon} ${C.bold}${result.name}${C.reset}`);
|
|
252
|
+
const steps = narrateTest(result);
|
|
253
|
+
for (const step of steps) {
|
|
254
|
+
console.log(` ${C.dim}${step}${C.reset}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
225
258
|
if (screenshotsDir) {
|
|
226
259
|
console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
|
|
227
260
|
console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
|