@matware/e2e-runner 1.1.1 → 1.3.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
@@ -0,0 +1,310 @@
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
+ import { KNOWN_ACTION_TYPES } from './actions.js';
17
+
18
+ /**
19
+ * Loads all module definitions from a directory.
20
+ * @param {string} modulesDir - Absolute path to modules directory
21
+ * @returns {Map<string, object>} Map of module name -> definition
22
+ */
23
+ export function loadModuleRegistry(modulesDir) {
24
+ const registry = new Map();
25
+
26
+ if (!modulesDir || !fs.existsSync(modulesDir)) {
27
+ return registry;
28
+ }
29
+
30
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json'));
31
+
32
+ for (const file of files) {
33
+ const filePath = path.join(modulesDir, file);
34
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
35
+
36
+ if (!data.$module) {
37
+ continue; // Not a module definition
38
+ }
39
+
40
+ if (registry.has(data.$module)) {
41
+ throw new Error(`Duplicate module name "${data.$module}" in ${file}`);
42
+ }
43
+
44
+ registry.set(data.$module, data);
45
+ }
46
+
47
+ return registry;
48
+ }
49
+
50
+ /**
51
+ * Replaces {{param}} placeholders and {{#param}}...{{/param}} conditionals in a string.
52
+ * @param {string} str - Template string
53
+ * @param {object} params - Parameter values
54
+ * @param {object} paramDefs - Parameter definitions from the module (for defaults)
55
+ * @returns {string} Resolved string
56
+ */
57
+ function substituteParams(str, params, paramDefs) {
58
+ if (typeof str !== 'string') return str;
59
+
60
+ // Build effective params: defaults + provided
61
+ const effective = {};
62
+ if (paramDefs) {
63
+ for (const [key, def] of Object.entries(paramDefs)) {
64
+ if (def.default !== undefined) {
65
+ effective[key] = def.default;
66
+ }
67
+ }
68
+ }
69
+ Object.assign(effective, params);
70
+
71
+ // Process conditional blocks: {{#key}}content{{/key}}
72
+ let result = str.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, content) => {
73
+ const val = effective[key];
74
+ if (val !== undefined && val !== '' && val !== null && val !== false) {
75
+ // Recursively substitute inside the block
76
+ return substituteParams(content, params, paramDefs);
77
+ }
78
+ return '';
79
+ });
80
+
81
+ // Process simple substitutions: {{key}}
82
+ result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
83
+ if (key in effective) return String(effective[key]);
84
+ return match; // Leave unresolved (will be caught by validation)
85
+ });
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Applies parameter substitution to all string fields of an action.
92
+ * @param {object} action - Action object
93
+ * @param {object} params - Parameter values
94
+ * @param {object} paramDefs - Parameter definitions
95
+ * @returns {object} New action with substituted values
96
+ */
97
+ function substituteActionParams(action, params, paramDefs, moduleName) {
98
+ const result = {};
99
+ for (const [key, value] of Object.entries(action)) {
100
+ if (typeof value === 'string') {
101
+ result[key] = substituteParams(value, params, paramDefs);
102
+ } else {
103
+ result[key] = value;
104
+ }
105
+ }
106
+
107
+ // Check for unresolved placeholders
108
+ for (const [key, value] of Object.entries(result)) {
109
+ if (typeof value === 'string') {
110
+ const unresolved = value.match(/\{\{(\w+)\}\}/g);
111
+ if (unresolved) {
112
+ const paramNames = unresolved.map(m => m.slice(2, -2));
113
+ throw new Error(`Module "${moduleName || 'unknown'}": unresolved parameter(s) ${paramNames.join(', ')} in "${key}". Provide them via params or define defaults.`);
114
+ }
115
+ }
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Validates that all required parameters have values.
123
+ * @param {object} paramDefs - Parameter definitions from module
124
+ * @param {object} params - Provided parameter values
125
+ * @param {string} moduleName - Module name for error messages
126
+ */
127
+ function validateParams(paramDefs, params, moduleName) {
128
+ if (!paramDefs) return;
129
+
130
+ for (const [key, def] of Object.entries(paramDefs)) {
131
+ if (def.required && !(key in params) && def.default === undefined) {
132
+ throw new Error(`Module "${moduleName}": missing required parameter "${key}"`);
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Resolves an array of actions, expanding $use references recursively.
139
+ * @param {Array} actions - Array of actions (may contain $use references)
140
+ * @param {Map} registry - Module registry
141
+ * @param {object} contextParams - Parameters from parent scope
142
+ * @param {Set} visited - Set of module names in the current resolution chain (cycle detection)
143
+ * @returns {Array} Flattened array of concrete actions
144
+ */
145
+ function resolveActions(actions, registry, contextParams = {}, visited = new Set()) {
146
+ const resolved = [];
147
+
148
+ for (const action of actions) {
149
+ if (action.$use) {
150
+ const moduleName = action.$use;
151
+
152
+ // Cycle detection
153
+ if (visited.has(moduleName)) {
154
+ throw new Error(`Circular module dependency detected: ${[...visited, moduleName].join(' -> ')}`);
155
+ }
156
+
157
+ const moduleDef = registry.get(moduleName);
158
+ if (!moduleDef) {
159
+ throw new Error(`Module not found: "${moduleName}". Available: ${[...registry.keys()].join(', ') || 'none'}`);
160
+ }
161
+
162
+ // Merge params: context params as fallback, then explicit params
163
+ const mergedParams = { ...contextParams, ...action.params };
164
+ validateParams(moduleDef.params, mergedParams, moduleName);
165
+
166
+ // Recursively resolve the module's actions
167
+ const newVisited = new Set(visited);
168
+ newVisited.add(moduleName);
169
+
170
+ const moduleActions = moduleDef.actions || [];
171
+ const substituted = moduleActions.map(a => {
172
+ if (a.$use) {
173
+ // Nested $use — pass through for recursive resolution
174
+ return { ...a, params: { ...mergedParams, ...a.params } };
175
+ }
176
+ return substituteActionParams(a, mergedParams, moduleDef.params, moduleName);
177
+ });
178
+
179
+ const expandedActions = resolveActions(substituted, registry, mergedParams, newVisited);
180
+ resolved.push(...expandedActions);
181
+ } else {
182
+ resolved.push(action);
183
+ }
184
+ }
185
+
186
+ return resolved;
187
+ }
188
+
189
+ /**
190
+ * Resolves all $use references in a test data structure.
191
+ * @param {object} data - { tests, hooks } as returned by normalizeTestData
192
+ * @param {string} modulesDir - Absolute path to modules directory
193
+ * @returns {object} New { tests, hooks } with all $use expanded
194
+ */
195
+ export function resolveTestData(data, modulesDir) {
196
+ if (!modulesDir || !fs.existsSync(modulesDir)) {
197
+ return data; // No modules directory — pass through unchanged
198
+ }
199
+
200
+ const registry = loadModuleRegistry(modulesDir);
201
+ if (registry.size === 0) {
202
+ return data; // No modules defined — pass through
203
+ }
204
+
205
+ // Check if there are any $use references to resolve
206
+ const hasUseRef = (actions) => actions?.some(a => a.$use);
207
+ const hooksNeedResolving = Object.values(data.hooks || {}).some(hasUseRef);
208
+ const testsNeedResolving = data.tests?.some(t => hasUseRef(t.actions));
209
+
210
+ if (!hooksNeedResolving && !testsNeedResolving) {
211
+ return data; // No $use references — pass through
212
+ }
213
+
214
+ // Resolve hooks
215
+ const resolvedHooks = {};
216
+ for (const [hookName, actions] of Object.entries(data.hooks || {})) {
217
+ if (Array.isArray(actions)) {
218
+ resolvedHooks[hookName] = resolveActions(actions, registry);
219
+ } else {
220
+ resolvedHooks[hookName] = actions;
221
+ }
222
+ }
223
+
224
+ // Resolve test actions
225
+ const resolvedTests = (data.tests || []).map(test => {
226
+ if (!hasUseRef(test.actions)) return test;
227
+ return {
228
+ ...test,
229
+ actions: resolveActions(test.actions, registry),
230
+ };
231
+ });
232
+
233
+ return { tests: resolvedTests, hooks: resolvedHooks };
234
+ }
235
+
236
+ /**
237
+ * Lists available modules with their metadata.
238
+ * @param {string} modulesDir - Absolute path to modules directory
239
+ * @returns {Array<{name, description, params, file}>} Module metadata
240
+ */
241
+ export function listModules(modulesDir) {
242
+ if (!modulesDir || !fs.existsSync(modulesDir)) {
243
+ return [];
244
+ }
245
+
246
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json'));
247
+ const modules = [];
248
+
249
+ for (const file of files) {
250
+ const filePath = path.join(modulesDir, file);
251
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
252
+
253
+ if (!data.$module) continue;
254
+
255
+ const paramList = data.params
256
+ ? Object.entries(data.params).map(([name, def]) => ({
257
+ name,
258
+ required: !!def.required,
259
+ default: def.default,
260
+ description: def.description,
261
+ }))
262
+ : [];
263
+
264
+ modules.push({
265
+ name: data.$module,
266
+ description: data.description || '',
267
+ file,
268
+ actionCount: (data.actions || []).length,
269
+ params: paramList,
270
+ });
271
+ }
272
+
273
+ return modules;
274
+ }
275
+
276
+ /**
277
+ * Validates that all action types in a resolved test data structure are known.
278
+ * Call AFTER module resolution so $use references have been expanded.
279
+ * @param {object} data - { tests, hooks } with resolved actions
280
+ * @param {string} context - File/suite name for error messages
281
+ * @throws {Error} if any unknown action types are found
282
+ */
283
+ export function validateActionTypes(data, context) {
284
+ const unknown = [];
285
+
286
+ const check = (actions, location) => {
287
+ if (!Array.isArray(actions)) return;
288
+ for (const action of actions) {
289
+ if (action.$use) continue; // unresolved module ref — skip (shouldn't happen post-resolution)
290
+ if (action.type && !KNOWN_ACTION_TYPES.has(action.type)) {
291
+ unknown.push({ type: action.type, location });
292
+ }
293
+ }
294
+ };
295
+
296
+ // Check hooks
297
+ for (const [hookName, actions] of Object.entries(data.hooks || {})) {
298
+ check(actions, `hooks.${hookName}`);
299
+ }
300
+
301
+ // Check test actions
302
+ for (const test of data.tests || []) {
303
+ check(test.actions, `test "${test.name}"`);
304
+ }
305
+
306
+ if (unknown.length > 0) {
307
+ const details = unknown.map(u => `"${u.type}" in ${u.location}`).join(', ');
308
+ throw new Error(`Unknown action type(s) in ${context}: ${details}`);
309
+ }
310
+ }
package/src/narrate.js ADDED
@@ -0,0 +1,262 @@
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 'set_storage': {
133
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
134
+ const eqIdx = value.indexOf('=');
135
+ const sKey = eqIdx === -1 ? value : value.slice(0, eqIdx);
136
+ const sVal = eqIdx === -1 ? '' : value.slice(eqIdx + 1);
137
+ const displayVal = isSensitive(sKey) ? '***' : (sVal.length > 30 ? sVal.slice(0, 27) + '...' : sVal);
138
+ return `Set ${storageType}.${sKey} = "${displayVal}"${time}`;
139
+ }
140
+
141
+ case 'assert_storage': {
142
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
143
+ const eqIdx = value.indexOf('=');
144
+ if (eqIdx === -1) {
145
+ return `Verified ${storageType} key "${value}" exists${time}`;
146
+ }
147
+ return `Verified ${storageType} key "${value.slice(0, eqIdx)}" has expected value${time}`;
148
+ }
149
+
150
+ case 'click_icon':
151
+ return `Clicked icon "${value}"${selector ? ` in "${selector}"` : ''}${time}`;
152
+
153
+ case 'click_menu_item':
154
+ return `Clicked menu item "${text}"${selector ? ` in "${selector}"` : ''}${time}`;
155
+
156
+ case 'click_in_context':
157
+ return `Clicked "${selector}" in container with text "${text}"${time}`;
158
+
159
+ case 'evaluate': {
160
+ const snippet = value.length > 80 ? value.slice(0, 77) + '...' : value;
161
+ const evalResult = result.result?.value;
162
+ if (evalResult !== undefined && evalResult !== null) {
163
+ const valStr = typeof evalResult === 'string' ? evalResult : JSON.stringify(evalResult);
164
+ const shortVal = valStr.length > 50 ? valStr.slice(0, 47) + '...' : valStr;
165
+ return `Executed JS: ${snippet} → ${shortVal}${time}`;
166
+ }
167
+ return `Executed JS: ${snippet}${time}`;
168
+ }
169
+
170
+ default:
171
+ return `Unknown action "${type}"${time}`;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Describes the intent of an action (used in failure messages).
177
+ */
178
+ function describeIntent(action) {
179
+ const { type, selector, value, text } = action;
180
+
181
+ switch (type) {
182
+ case 'goto': return `Navigate to ${value}`;
183
+ case 'click': return selector ? `Click on "${selector}"` : `Click on text "${text}"`;
184
+ case 'type':
185
+ case 'fill': return `Type into "${selector}"`;
186
+ case 'wait':
187
+ if (selector) return `Wait for "${selector}"`;
188
+ if (text) return `Wait for text "${text}"`;
189
+ return `Wait ${value}ms`;
190
+ case 'screenshot': return 'Capture screenshot';
191
+ case 'assert_text': return `Assert text "${text}" present`;
192
+ case 'assert_url': return `Assert URL contains "${value}"`;
193
+ case 'assert_visible': return `Assert "${selector}" visible`;
194
+ case 'assert_count': return `Assert "${selector}" count = ${value}`;
195
+ case 'assert_element_text': return `Assert "${selector}" contains "${text}"`;
196
+ case 'assert_attribute': return `Assert attribute on "${selector}": ${value}`;
197
+ case 'assert_class': return `Assert "${selector}" has class "${value}"`;
198
+ case 'assert_not_visible': return `Assert "${selector}" not visible`;
199
+ case 'assert_input_value': return `Assert input "${selector}" value "${value}"`;
200
+ case 'assert_matches': return `Assert "${selector}" matches /${value}/`;
201
+ case 'get_text': return `Get text from "${selector}"`;
202
+ case 'assert_no_network_errors': return 'Assert no network errors';
203
+ case 'select': return `Select "${value}" in "${selector}"`;
204
+ case 'clear': return `Clear "${selector}"`;
205
+ case 'press': return `Press key "${value}"`;
206
+ case 'scroll': return selector ? `Scroll to "${selector}"` : `Scroll down`;
207
+ case 'hover': return `Hover over "${selector}"`;
208
+ case 'clear_cookies': return 'Clear cookies and storage';
209
+ case 'navigate': return `Navigate to ${value}`;
210
+ case 'type_react': return `Type into React input "${selector}"`;
211
+ case 'click_regex': return `Click element matching /${text}/i`;
212
+ case 'click_option': return `Click option "${text}"`;
213
+ case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
214
+ case 'click_chip': return `Click chip "${text}"`;
215
+ case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
216
+ case 'assert_storage': {
217
+ const eqIdx = value?.indexOf('=') ?? -1;
218
+ return eqIdx === -1
219
+ ? `Assert ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value}" exists`
220
+ : `Assert ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value.slice(0, eqIdx)}" value`;
221
+ }
222
+ case 'click_icon': return `Click icon "${value}"`;
223
+ case 'click_menu_item': return `Click menu item "${text}"`;
224
+ case 'click_in_context': return `Click "${selector}" in context of "${text}"`;
225
+ case 'evaluate': return 'Execute JS';
226
+ default: return `Action "${type}"`;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Checks if a selector likely refers to a sensitive field (password, token, etc.)
232
+ */
233
+ function isSensitive(selector) {
234
+ if (!selector) return false;
235
+ return /password|secret|token|pin|ssn|cvv/i.test(selector);
236
+ }
237
+
238
+ /**
239
+ * Builds a full test narrative from the result's actions array.
240
+ * Returns a numbered step-by-step summary.
241
+ *
242
+ * @param {object} testResult — A single test result with actions[], name, success, error
243
+ * @returns {string[]} Array of narrative lines
244
+ */
245
+ export function narrateTest(testResult) {
246
+ const lines = [];
247
+
248
+ for (let i = 0; i < testResult.actions.length; i++) {
249
+ const action = testResult.actions[i];
250
+ const narrative = action.narrative || narrateAction(action, action);
251
+ lines.push(`${i + 1}. ${narrative}`);
252
+ }
253
+
254
+ if (!testResult.success && testResult.error) {
255
+ const failedAt = testResult.actions.findIndex(a => !a.success);
256
+ if (failedAt === -1) {
257
+ lines.push(`✗ Test failed: ${testResult.error}`);
258
+ }
259
+ }
260
+
261
+ return lines;
262
+ }
@@ -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
+ }