@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.
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- 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
|
+
}
|