@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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +475 -307
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +194 -6
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +10 -2
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +273 -18
  17. package/src/ai-generate.js +87 -7
  18. package/src/config.js +28 -0
  19. package/src/dashboard.js +156 -6
  20. package/src/db.js +207 -13
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +448 -18
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +35 -2
  31. package/src/runner.js +120 -46
  32. package/src/verify.js +5 -3
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +964 -378
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. 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`);