@matware/e2e-runner 1.3.1 → 1.5.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 (47) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +110 -21
  4. package/agents/test-creator.md +4 -2
  5. package/agents/test-improver.md +5 -3
  6. package/bin/cli.js +80 -17
  7. package/package.json +3 -2
  8. package/skills/e2e-testing/SKILL.md +3 -2
  9. package/skills/e2e-testing/references/action-types.md +22 -4
  10. package/skills/e2e-testing/references/test-json-format.md +23 -0
  11. package/src/actions.js +170 -14
  12. package/src/config.js +6 -0
  13. package/src/dashboard.js +135 -4
  14. package/src/db.js +11 -0
  15. package/src/mcp-tools.js +8 -2
  16. package/src/module-analysis.js +247 -0
  17. package/src/module-resolver.js +35 -2
  18. package/src/narrate.js +14 -1
  19. package/src/pool-manager.js +46 -1
  20. package/src/pool.js +177 -20
  21. package/src/runner.js +77 -10
  22. package/src/visual-diff.js +69 -0
  23. package/src/websocket.js +14 -3
  24. package/src/wizard.js +184 -0
  25. package/templates/build-dashboard.js +3 -0
  26. package/templates/dashboard/js/api.js +60 -3
  27. package/templates/dashboard/js/init.js +46 -0
  28. package/templates/dashboard/js/keyboard.js +8 -7
  29. package/templates/dashboard/js/quicksearch.js +277 -0
  30. package/templates/dashboard/js/state.js +61 -7
  31. package/templates/dashboard/js/toast.js +1 -1
  32. package/templates/dashboard/js/view-live.js +235 -42
  33. package/templates/dashboard/js/view-runs.js +379 -37
  34. package/templates/dashboard/js/view-tests.js +157 -16
  35. package/templates/dashboard/js/view-tools.js +234 -0
  36. package/templates/dashboard/js/view-watch.js +2 -2
  37. package/templates/dashboard/js/websocket.js +33 -3
  38. package/templates/dashboard/styles/base.css +489 -53
  39. package/templates/dashboard/styles/components.css +719 -84
  40. package/templates/dashboard/styles/view-live.css +459 -78
  41. package/templates/dashboard/styles/view-runs.css +779 -177
  42. package/templates/dashboard/styles/view-tests.css +440 -77
  43. package/templates/dashboard/styles/view-tools.css +206 -0
  44. package/templates/dashboard/styles/view-watch.css +198 -41
  45. package/templates/dashboard/template.html +354 -56
  46. package/templates/dashboard.html +5173 -711
  47. package/templates/docker-compose-lightpanda.yml +7 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Module Analysis — deterministic duplication detector.
3
+ *
4
+ * Scans every test JSON in testsDir, normalizes action sequences to
5
+ * signatures (literal values become placeholders, but selectors/text
6
+ * stay so two unrelated clicks don't collide), and reports sequences of
7
+ * length 3-8 that appear in 2+ different tests. These are the canonical
8
+ * candidates the test-improver agent would extract into a `$use` module.
9
+ *
10
+ * Also enumerates current modules and counts how often each is referenced
11
+ * via `$use` across the project so users can see adoption.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ const MIN_SEQ_LEN = 3;
18
+ const MAX_SEQ_LEN = 8;
19
+
20
+ /** Stable signature for one action — literals → '*', identifiers kept. */
21
+ function signatureOf(action) {
22
+ if (!action || typeof action !== 'object') return '?';
23
+ if (action.$use) return `$use:${action.$use}`;
24
+ const type = action.type || '?';
25
+ // Keep selector + text (semantic identifiers); replace `value` with `*`
26
+ // since values are usually parameterizable.
27
+ const parts = [type];
28
+ if (action.selector) parts.push(`@${action.selector}`);
29
+ if (action.text != null) parts.push(`"${String(action.text).slice(0, 40)}"`);
30
+ if (action.value != null) parts.push('*');
31
+ return parts.join('|');
32
+ }
33
+
34
+ function walkTests(testsDir) {
35
+ const out = [];
36
+ let files = [];
37
+ try {
38
+ files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
39
+ } catch { return out; }
40
+ for (const file of files) {
41
+ const fp = path.join(testsDir, file);
42
+ let suite;
43
+ try { suite = JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { continue; }
44
+ const tests = Array.isArray(suite) ? suite : suite.tests || [];
45
+ for (const t of tests) {
46
+ if (!t || !Array.isArray(t.actions)) continue;
47
+ out.push({
48
+ file,
49
+ suite: file.replace(/\.json$/, ''),
50
+ test: t.name || '(unnamed)',
51
+ actions: t.actions,
52
+ signatures: t.actions.map(signatureOf),
53
+ });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function findCandidates(tests) {
60
+ // Map signature-sequence-string → [{testIdx, start}]
61
+ const seen = new Map();
62
+ for (let ti = 0; ti < tests.length; ti++) {
63
+ const sig = tests[ti].signatures;
64
+ for (let len = MIN_SEQ_LEN; len <= MAX_SEQ_LEN; len++) {
65
+ for (let i = 0; i + len <= sig.length; i++) {
66
+ const key = sig.slice(i, i + len).join(' >> ');
67
+ if (!seen.has(key)) seen.set(key, []);
68
+ seen.get(key).push({ testIdx: ti, start: i, len });
69
+ }
70
+ }
71
+ }
72
+
73
+ const candidates = [];
74
+ for (const [key, hits] of seen) {
75
+ // Distinct tests, not just same test repeated
76
+ const distinct = new Map();
77
+ for (const h of hits) {
78
+ const t = tests[h.testIdx];
79
+ const id = t.suite + '::' + t.test;
80
+ if (!distinct.has(id)) distinct.set(id, { test: t, hits: [] });
81
+ distinct.get(id).hits.push(h);
82
+ }
83
+ if (distinct.size < 2) continue;
84
+ candidates.push({
85
+ signature: key,
86
+ length: hits[0].len,
87
+ occurrenceCount: hits.length,
88
+ testCount: distinct.size,
89
+ // Best representative — first hit's actions, lifted from the test
90
+ sample: tests[hits[0].testIdx].actions.slice(hits[0].start, hits[0].start + hits[0].len),
91
+ usedBy: [...distinct.values()].map(d => ({
92
+ suite: d.test.suite,
93
+ test: d.test.test,
94
+ occurrences: d.hits.length,
95
+ })),
96
+ });
97
+ }
98
+
99
+ // Rank: maximize (savings ≈ length * (testCount - 1))
100
+ // Then prefer longer sequences over shorter ones.
101
+ candidates.sort((a, b) => {
102
+ const savingsA = a.length * (a.testCount - 1);
103
+ const savingsB = b.length * (b.testCount - 1);
104
+ if (savingsA !== savingsB) return savingsB - savingsA;
105
+ return b.length - a.length;
106
+ });
107
+
108
+ // Prune: drop sequences that are strict substrings of a higher-scored one
109
+ // covering the same set of tests (the shorter one is redundant once the
110
+ // longer one is extracted).
111
+ const kept = [];
112
+ for (const c of candidates) {
113
+ const covered = kept.find(k =>
114
+ k.signature.includes(c.signature) &&
115
+ JSON.stringify(k.usedBy.map(u => u.suite+'::'+u.test).sort()) ===
116
+ JSON.stringify(c.usedBy.map(u => u.suite+'::'+u.test).sort())
117
+ );
118
+ if (!covered) kept.push(c);
119
+ }
120
+
121
+ // Suggest a name from the dominant action types
122
+ for (const c of kept) {
123
+ c.suggestedName = suggestModuleName(c.sample);
124
+ }
125
+
126
+ return kept.slice(0, 30);
127
+ }
128
+
129
+ function suggestModuleName(actions) {
130
+ if (!actions || !actions.length) return 'module';
131
+ const types = actions.map(a => a?.type).filter(Boolean);
132
+ // Heuristics for common patterns
133
+ const hasGoto = types.includes('goto');
134
+ const hasType = types.includes('type') || types.includes('fill') || types.includes('type_react');
135
+ const hasClick = types.some(t => t && t.startsWith('click'));
136
+ const hasAssert = types.some(t => t && t.startsWith('assert'));
137
+ // Pull a noun-y hint from selector or text of first non-goto action
138
+ const hint = (function () {
139
+ for (const a of actions) {
140
+ const s = a?.text || a?.selector || a?.value;
141
+ if (typeof s === 'string' && s.length > 0 && s.length < 30) {
142
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
143
+ }
144
+ }
145
+ return '';
146
+ })();
147
+ if (hasGoto && hasType && hasClick) return 'navigate-and-submit' + (hint ? '-' + hint : '');
148
+ if (hasType && hasClick) return 'fill-form' + (hint ? '-' + hint : '');
149
+ if (hasGoto && hasAssert) return 'open-and-verify' + (hint ? '-' + hint : '');
150
+ if (hasGoto) return 'navigate' + (hint ? '-' + hint : '');
151
+ if (hint) return hint;
152
+ return 'extracted-module';
153
+ }
154
+
155
+ function loadModules(modulesDir) {
156
+ if (!modulesDir || !fs.existsSync(modulesDir)) return [];
157
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
158
+ return files.map(f => {
159
+ const fp = path.join(modulesDir, f);
160
+ let data = {};
161
+ try { data = JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { /* */ }
162
+ return {
163
+ name: f.replace(/\.json$/, ''),
164
+ file: f,
165
+ description: data.description || null,
166
+ params: data.params || [],
167
+ actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
168
+ };
169
+ });
170
+ }
171
+
172
+ function countModuleUsage(tests, modules) {
173
+ const usage = new Map(modules.map(m => [m.name, { count: 0, usedBy: new Set() }]));
174
+ function walk(actions, testInfo) {
175
+ if (!Array.isArray(actions)) return;
176
+ for (const a of actions) {
177
+ if (a && a.$use) {
178
+ const u = usage.get(a.$use);
179
+ if (u) { u.count++; u.usedBy.add(testInfo); }
180
+ }
181
+ }
182
+ }
183
+ for (const t of tests) {
184
+ walk(t.actions, t.suite + '::' + t.test);
185
+ }
186
+ return modules.map(m => {
187
+ const u = usage.get(m.name) || { count: 0, usedBy: new Set() };
188
+ return { ...m, usageCount: u.count, usedBy: [...u.usedBy] };
189
+ });
190
+ }
191
+
192
+ export function runModuleAnalysis(testsDir, modulesDir) {
193
+ const tests = walkTests(testsDir);
194
+ const modules = loadModules(modulesDir);
195
+ const candidates = findCandidates(tests);
196
+ const modulesWithUsage = countModuleUsage(tests, modules);
197
+
198
+ // Build a Claude Code prompt the user can copy verbatim.
199
+ const prompt = buildAgentPrompt(testsDir, modulesDir, candidates, modulesWithUsage);
200
+
201
+ return {
202
+ testsDir,
203
+ modulesDir,
204
+ summary: {
205
+ testCount: tests.length,
206
+ moduleCount: modulesWithUsage.length,
207
+ candidateCount: candidates.length,
208
+ unusedModules: modulesWithUsage.filter(m => m.usageCount === 0).length,
209
+ },
210
+ modules: modulesWithUsage,
211
+ candidates,
212
+ agentPrompt: prompt,
213
+ };
214
+ }
215
+
216
+ function buildAgentPrompt(testsDir, modulesDir, candidates, modules) {
217
+ const topCandidates = candidates.slice(0, 10);
218
+ return [
219
+ 'Analyze E2E test modules and recommend changes.',
220
+ '',
221
+ `Tests directory: ${testsDir}`,
222
+ `Modules directory: ${modulesDir}`,
223
+ '',
224
+ 'Use the test-improver capabilities to:',
225
+ '1. Review the candidate sequences below and decide which should be extracted into reusable modules via `e2e_create_module`.',
226
+ '2. For each extracted module, suggest the parameters needed (selectors/text that vary between usages).',
227
+ '3. Check the current modules list for any that are unused or could be consolidated.',
228
+ '4. After creating modules, Edit the affected test files to replace inline action sequences with `{ "$use": "<module-name>", "params": {...} }`.',
229
+ '',
230
+ `## Top ${topCandidates.length} extraction candidates`,
231
+ '',
232
+ ...topCandidates.map((c, i) =>
233
+ `${i + 1}. **${c.suggestedName}** (${c.length} actions, used in ${c.testCount} tests, ${c.occurrenceCount} total occurrences)\n` +
234
+ ` Signature: \`${c.signature}\`\n` +
235
+ ` Used by: ${c.usedBy.map(u => `${u.suite}::${u.test}`).join(', ')}`
236
+ ),
237
+ '',
238
+ '## Current modules',
239
+ '',
240
+ ...modules.map(m =>
241
+ `- **${m.name}** — ${m.actionCount} actions, ${m.params.length} params, used ${m.usageCount}x` +
242
+ (m.description ? `\n > ${m.description}` : '')
243
+ ),
244
+ '',
245
+ 'After making changes, run the affected tests to confirm nothing broke.',
246
+ ].join('\n');
247
+ }
@@ -14,6 +14,7 @@
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { KNOWN_ACTION_TYPES } from './actions.js';
17
+ import { KNOWN_DRIVERS } from './pool.js';
17
18
 
18
19
  /**
19
20
  * Loads all module definitions from a directory.
@@ -170,8 +171,17 @@ function resolveActions(actions, registry, contextParams = {}, visited = new Set
170
171
  const moduleActions = moduleDef.actions || [];
171
172
  const substituted = moduleActions.map(a => {
172
173
  if (a.$use) {
173
- // Nested $use — pass through for recursive resolution
174
- return { ...a, params: { ...mergedParams, ...a.params } };
174
+ // Nested $use — resolve {{param}} placeholders in the nested call's
175
+ // params against THIS module's scope (its merged params + defaults)
176
+ // so a module can forward its own params/defaults to a module it
177
+ // $uses. Then merge over the inherited context params as fallback.
178
+ const nestedParams = {};
179
+ for (const [k, v] of Object.entries(a.params || {})) {
180
+ nestedParams[k] = typeof v === 'string'
181
+ ? substituteParams(v, mergedParams, moduleDef.params)
182
+ : v;
183
+ }
184
+ return { ...a, params: { ...mergedParams, ...nestedParams } };
175
185
  }
176
186
  return substituteActionParams(a, mergedParams, moduleDef.params, moduleName);
177
187
  });
@@ -307,4 +317,27 @@ export function validateActionTypes(data, context) {
307
317
  const details = unknown.map(u => `"${u.type}" in ${u.location}`).join(', ');
308
318
  throw new Error(`Unknown action type(s) in ${context}: ${details}`);
309
319
  }
320
+
321
+ // Validate per-test driver / fallbackDriver fields
322
+ const knownExceptAuto = [...KNOWN_DRIVERS].filter(d => d !== 'auto');
323
+ for (const test of data.tests || []) {
324
+ if (test.driver !== undefined && !knownExceptAuto.includes(test.driver)) {
325
+ throw new Error(
326
+ `Invalid driver "${test.driver}" in test "${test.name}" (${context}). ` +
327
+ `Allowed: ${knownExceptAuto.join(', ')}.`
328
+ );
329
+ }
330
+ if (test.fallbackDriver !== undefined && !knownExceptAuto.includes(test.fallbackDriver)) {
331
+ throw new Error(
332
+ `Invalid fallbackDriver "${test.fallbackDriver}" in test "${test.name}" (${context}). ` +
333
+ `Allowed: ${knownExceptAuto.join(', ')}.`
334
+ );
335
+ }
336
+ if (test.fallbackDriver !== undefined && test.driver === undefined) {
337
+ throw new Error(
338
+ `Test "${test.name}" (${context}) declares fallbackDriver without driver. ` +
339
+ `fallbackDriver only applies when driver is set.`
340
+ );
341
+ }
342
+ }
310
343
  }
package/src/narrate.js CHANGED
@@ -36,15 +36,24 @@ export function narrateAction(action, result) {
36
36
  return `Typed "${masked}" into "${selector}"${time}`;
37
37
  }
38
38
 
39
- case 'wait':
39
+ case 'wait': {
40
+ const goneTarget = typeof action.gone === 'string' ? action.gone : (action.gone === true ? (selector || (text ? `text "${text}"` : null)) : null);
41
+ if (goneTarget) return `Waited for ${goneTarget.startsWith('text ') ? goneTarget : `"${goneTarget}"`} to disappear${time}`;
40
42
  if (selector) return `Waited for "${selector}" to appear${time}`;
41
43
  if (text) return `Waited for text "${text}" to appear${time}`;
42
44
  return `Waited ${value}ms`;
45
+ }
43
46
 
44
47
  case 'screenshot':
45
48
  if (result.result?.screenshot) {
46
49
  return `Captured screenshot: ${result.result.screenshot}`;
47
50
  }
51
+ if (result.result?.skipped) {
52
+ const reason = result.result.skipped === 'blank-page'
53
+ ? 'page was blank'
54
+ : 'render looked blank';
55
+ return `Skipped screenshot (${reason})`;
56
+ }
48
57
  return `Captured screenshot${value ? `: ${value}` : ''}`;
49
58
 
50
59
  case 'assert_text':
@@ -123,6 +132,9 @@ export function narrateAction(action, result) {
123
132
  case 'click_option':
124
133
  return `Clicked dropdown option "${text}"${time}`;
125
134
 
135
+ case 'select_combobox':
136
+ return `Selected "${text || action.option}" from combobox${action.filter ? ` (filtered "${action.filter}")` : ''}${time}`;
137
+
126
138
  case 'focus_autocomplete':
127
139
  return `Focused autocomplete labeled "${text}"${time}`;
128
140
 
@@ -232,6 +244,7 @@ function describeIntent(action) {
232
244
  case 'type_react': return `Type into React input "${selector}"`;
233
245
  case 'click_regex': return `Click element matching /${text}/i`;
234
246
  case 'click_option': return `Click option "${text}"`;
247
+ case 'select_combobox': return `Select "${text || action.option}" from combobox`;
235
248
  case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
236
249
  case 'click_chip': return `Click chip "${text}"`;
237
250
  case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
@@ -11,7 +11,7 @@
11
11
  * subsequent calls factor in connections that are in-flight.
12
12
  */
13
13
 
14
- import { getPoolStatus, connectToPool } from './pool.js';
14
+ import { getPoolStatus, connectToPool, getCachedDriver } from './pool.js';
15
15
  import { log, colors as C } from './logger.js';
16
16
 
17
17
  function sleep(ms) {
@@ -201,6 +201,51 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
201
201
  return poolUrls[0];
202
202
  }
203
203
 
204
+ /**
205
+ * Filters pool URLs to those whose detected driver matches `driver`,
206
+ * with explicit opt-in fallback to `fallbackDriver`.
207
+ *
208
+ * Probes all pools once via getAllPoolStatuses() to warm the per-URL driver cache,
209
+ * then filters by getCachedDriver(url). Pools that are unreachable have a null
210
+ * detected driver and are excluded.
211
+ *
212
+ * Throws if no pool matches the requested driver and no usable fallback exists.
213
+ * Pool busyness is NOT a fallback trigger — selectPool() handles capacity waits
214
+ * inside the filtered set.
215
+ *
216
+ * @param {string[]} poolUrls
217
+ * @param {string} driver - Required driver name (e.g. 'obscura')
218
+ * @param {string|null} fallbackDriver - Explicit fallback driver, or null/undefined for hard error
219
+ * @param {object} options - { poolDriver, maxSessions } passed to getPoolStatus
220
+ * @returns {Promise<{urls: string[], driver: string, usedFallback: boolean}>}
221
+ */
222
+ export async function resolvePoolsForTest(poolUrls, driver, fallbackDriver, options = {}) {
223
+ // Warm driver cache for all reachable pools
224
+ await getAllPoolStatuses(poolUrls, options);
225
+
226
+ const matching = poolUrls.filter(url => getCachedDriver(url) === driver);
227
+ if (matching.length > 0) {
228
+ return { urls: matching, driver, usedFallback: false };
229
+ }
230
+
231
+ if (fallbackDriver) {
232
+ const fallbackMatching = poolUrls.filter(url => getCachedDriver(url) === fallbackDriver);
233
+ if (fallbackMatching.length > 0) {
234
+ log('⚠️', `${C.yellow}No pool with driver=${driver}, falling back to ${fallbackDriver}${C.reset}`);
235
+ return { urls: fallbackMatching, driver: fallbackDriver, usedFallback: true };
236
+ }
237
+ throw new Error(
238
+ `No pool available for driver "${driver}" and fallback driver "${fallbackDriver}" also unavailable. ` +
239
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
240
+ );
241
+ }
242
+
243
+ throw new Error(
244
+ `No pool available for driver "${driver}" and no fallbackDriver specified. ` +
245
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
246
+ );
247
+ }
248
+
204
249
  /** Convenience: selectPool + connectToPool in one call. */
205
250
  export async function selectAndConnect(config) {
206
251
  const poolUrls = getPoolUrls(config);