@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
package/src/learner.js ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Learning engine — extracts entities from a test run and persists them to SQLite.
3
+ *
4
+ * Called after every run via persistRun() in reporter.js.
5
+ * All writes are fast synchronous INSERTs — never blocks the runner.
6
+ */
7
+
8
+ import { getDb } from './db.js';
9
+ import { writeToGraph } from './learner-neo4j.js';
10
+
11
+ // ── Error categorization ──────────────────────────────────────────────────────
12
+
13
+ const ERROR_CATEGORIES = [
14
+ { pattern: /timeout/i, category: 'timeout' },
15
+ { pattern: /waiting for selector/i, category: 'selector-not-found' },
16
+ { pattern: /no element found/i, category: 'selector-not-found' },
17
+ { pattern: /waitForSelector/i, category: 'selector-not-found' },
18
+ { pattern: /not visible/i, category: 'selector-not-found' },
19
+ { pattern: /navigation/i, category: 'navigation-error' },
20
+ { pattern: /net::ERR_/i, category: 'connection-refused' },
21
+ { pattern: /ERR_CONNECTION_REFUSED/i, category: 'connection-refused' },
22
+ { pattern: /assert_text/i, category: 'assert-text-failed' },
23
+ { pattern: /assert_url/i, category: 'assert-url-failed' },
24
+ { pattern: /assert_visible/i, category: 'assert-visible-failed' },
25
+ { pattern: /assert_count/i, category: 'assert-count-failed' },
26
+ { pattern: /assert_element_text/i, category: 'assert-element-text-failed' },
27
+ { pattern: /assert_attribute/i, category: 'assert-attribute-failed' },
28
+ { pattern: /assert_class/i, category: 'assert-class-failed' },
29
+ { pattern: /assert_not_visible/i, category: 'assert-not-visible-failed' },
30
+ { pattern: /assert_input_value/i, category: 'assert-input-value-failed' },
31
+ { pattern: /assert_matches/i, category: 'assert-matches-failed' },
32
+ { pattern: /assert_no_network_errors/i, category: 'assert-network-failed' },
33
+ { pattern: /evaluate returned false/i, category: 'evaluate-error' },
34
+ { pattern: /evaluate.*FAIL/i, category: 'evaluate-error' },
35
+ { pattern: /evaluate.*ERROR/i, category: 'evaluate-error' },
36
+ ];
37
+
38
+ export function categorizeError(errorMsg) {
39
+ if (!errorMsg) return { category: 'unknown', pattern: 'unknown' };
40
+
41
+ for (const { pattern, category } of ERROR_CATEGORIES) {
42
+ if (pattern.test(errorMsg)) {
43
+ return { category, pattern: normalizeErrorPattern(errorMsg, category) };
44
+ }
45
+ }
46
+
47
+ return { category: 'unknown', pattern: normalizeErrorPattern(errorMsg, 'unknown') };
48
+ }
49
+
50
+ /**
51
+ * Normalizes an error message into a stable pattern by stripping variable parts
52
+ * (selectors, URLs, numbers) so similar errors group together.
53
+ */
54
+ function normalizeErrorPattern(errorMsg, category) {
55
+ let normalized = errorMsg;
56
+
57
+ // Strip timeout values
58
+ normalized = normalized.replace(/\d+ms/g, 'Nms');
59
+ // Strip specific selectors in quotes
60
+ normalized = normalized.replace(/"[^"]+"/g, '"..."');
61
+ normalized = normalized.replace(/'[^']+'/g, "'...'");
62
+ // Strip URLs
63
+ normalized = normalized.replace(/https?:\/\/[^\s)]+/g, '<url>');
64
+ // Strip line/col numbers
65
+ normalized = normalized.replace(/:\d+:\d+/g, ':N:N');
66
+ // Collapse whitespace
67
+ normalized = normalized.replace(/\s+/g, ' ').trim();
68
+
69
+ // Cap length
70
+ if (normalized.length > 200) {
71
+ normalized = normalized.slice(0, 200) + '...';
72
+ }
73
+
74
+ return normalized;
75
+ }
76
+
77
+ // ── Path normalization ────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Normalizes variable path segments so similar URLs group together.
81
+ * Order matters: UUIDs first (most specific), then hex hashes, base64 tokens, numeric IDs.
82
+ */
83
+ function normalizePath(urlPath) {
84
+ return urlPath
85
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:uuid')
86
+ .replace(/\/[0-9a-f]{8,}/gi, '/:hash')
87
+ .replace(/\/[A-Za-z0-9_-]{20,}/g, '/:token')
88
+ .replace(/\/\d+/g, '/:id');
89
+ }
90
+
91
+ // ── Entity extraction ─────────────────────────────────────────────────────────
92
+
93
+ /** Extracts page URLs from a test result's actions (goto/navigate). */
94
+ function extractPages(result) {
95
+ const pages = [];
96
+ if (!result.actions) return pages;
97
+
98
+ for (const action of result.actions) {
99
+ if ((action.type === 'goto' || action.type === 'navigate') && action.value) {
100
+ // Normalize URL to path only, with variable segments collapsed
101
+ let urlPath = action.value;
102
+ try {
103
+ const url = new URL(urlPath, 'http://placeholder');
104
+ urlPath = url.pathname;
105
+ } catch { /* keep as-is */ }
106
+ urlPath = normalizePath(urlPath);
107
+ pages.push(urlPath);
108
+ }
109
+ }
110
+ return pages;
111
+ }
112
+
113
+ /** Extracts selectors and their action types from a test result's actions. */
114
+ function extractSelectors(result) {
115
+ const selectors = [];
116
+ if (!result.actions) return selectors;
117
+
118
+ let currentPage = '/';
119
+ for (const action of result.actions) {
120
+ if ((action.type === 'goto' || action.type === 'navigate') && action.value) {
121
+ try {
122
+ const url = new URL(action.value, 'http://placeholder');
123
+ currentPage = url.pathname;
124
+ } catch {
125
+ currentPage = action.value;
126
+ }
127
+ }
128
+
129
+ if (action.selector) {
130
+ selectors.push({
131
+ selector: action.selector,
132
+ actionType: action.type,
133
+ pageUrl: currentPage,
134
+ success: action.error ? 0 : 1,
135
+ error: action.error || null,
136
+ });
137
+ }
138
+ }
139
+ return selectors;
140
+ }
141
+
142
+ /**
143
+ * Extracts API endpoints from network logs.
144
+ * Normalizes URL to "METHOD /path" — strips host, query params, and variable IDs.
145
+ */
146
+ function extractApiEndpoints(result) {
147
+ const endpoints = [];
148
+ if (!result.networkLogs?.length) return endpoints;
149
+
150
+ for (const log of result.networkLogs) {
151
+ if (!log.url || !log.method) continue;
152
+
153
+ let urlPath;
154
+ try {
155
+ const url = new URL(log.url);
156
+ urlPath = url.pathname;
157
+ } catch {
158
+ urlPath = log.url;
159
+ }
160
+
161
+ urlPath = normalizePath(urlPath);
162
+
163
+ const endpoint = `${log.method} ${urlPath}`;
164
+ const isError = log.status >= 400 || log.status === 0;
165
+
166
+ endpoints.push({
167
+ endpoint,
168
+ method: log.method,
169
+ status: log.status || 0,
170
+ durationMs: log.duration || 0,
171
+ isError: isError ? 1 : 0,
172
+ });
173
+ }
174
+ return endpoints;
175
+ }
176
+
177
+ // ── Main learning function ────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Analyzes a completed run and writes learnings to SQLite.
181
+ * Called fire-and-forget after persistRun() — never throws.
182
+ */
183
+ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
184
+ const d = getDb();
185
+ const { results } = report;
186
+
187
+ const insertTestLearning = d.prepare(`
188
+ INSERT INTO test_learnings (project_id, run_id, test_name, success, duration_ms, flaky, attempt, max_attempts, error_pattern)
189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
190
+ `);
191
+
192
+ const insertSelectorLearning = d.prepare(`
193
+ INSERT INTO selector_learnings (project_id, run_id, selector, action_type, success, page_url, test_name, error)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
195
+ `);
196
+
197
+ const insertPageLearning = d.prepare(`
198
+ INSERT INTO page_learnings (project_id, run_id, url_path, load_time_ms, console_errors, console_warns, network_errors, test_name, success)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
200
+ `);
201
+
202
+ const insertApiLearning = d.prepare(`
203
+ INSERT INTO api_learnings (project_id, run_id, endpoint, method, status, duration_ms, is_error, test_name)
204
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
205
+ `);
206
+
207
+ const upsertErrorPattern = d.prepare(`
208
+ INSERT INTO error_patterns (project_id, pattern, category, occurrence_count, first_seen, last_seen, example_error, example_test)
209
+ VALUES (?, ?, ?, 1, datetime('now'), datetime('now'), ?, ?)
210
+ ON CONFLICT(project_id, pattern) DO UPDATE SET
211
+ occurrence_count = occurrence_count + 1,
212
+ last_seen = datetime('now'),
213
+ example_error = excluded.example_error,
214
+ example_test = excluded.example_test
215
+ `);
216
+
217
+ const tx = d.transaction(() => {
218
+ for (const result of results) {
219
+ const durationMs = (result.endTime && result.startTime)
220
+ ? new Date(result.endTime) - new Date(result.startTime)
221
+ : null;
222
+ const isFlaky = result.success && (result.attempt || 1) > 1 ? 1 : 0;
223
+
224
+ // Categorize error
225
+ let errorPattern = null;
226
+ if (result.error) {
227
+ const { category, pattern } = categorizeError(result.error);
228
+ errorPattern = category;
229
+
230
+ // Track error pattern
231
+ upsertErrorPattern.run(projectId, pattern, category, result.error, result.name);
232
+ }
233
+
234
+ // Test-level learning
235
+ insertTestLearning.run(
236
+ projectId, runDbId, result.name,
237
+ result.success ? 1 : 0, durationMs, isFlaky,
238
+ result.attempt || 1, result.maxAttempts || 1,
239
+ errorPattern
240
+ );
241
+
242
+ // Selector learnings
243
+ const selectors = extractSelectors(result);
244
+ for (const sel of selectors) {
245
+ insertSelectorLearning.run(
246
+ projectId, runDbId,
247
+ sel.selector, sel.actionType,
248
+ sel.success, sel.pageUrl,
249
+ result.name, sel.error
250
+ );
251
+ }
252
+
253
+ // Page learnings
254
+ const pages = extractPages(result);
255
+ const consoleErrors = (result.consoleLogs || []).filter(l => l.type === 'error').length;
256
+ const consoleWarns = (result.consoleLogs || []).filter(l => l.type === 'warning').length;
257
+ const networkErrors = (result.networkErrors || []).length;
258
+
259
+ for (const urlPath of pages) {
260
+ insertPageLearning.run(
261
+ projectId, runDbId,
262
+ urlPath, durationMs,
263
+ consoleErrors, consoleWarns, networkErrors,
264
+ result.name, result.success ? 1 : 0
265
+ );
266
+ }
267
+
268
+ // API endpoint learnings
269
+ const apiEndpoints = extractApiEndpoints(result);
270
+ for (const api of apiEndpoints) {
271
+ insertApiLearning.run(
272
+ projectId, runDbId,
273
+ api.endpoint, api.method,
274
+ api.status, api.durationMs,
275
+ api.isError, result.name
276
+ );
277
+ }
278
+ }
279
+ });
280
+
281
+ tx();
282
+
283
+ // Update the cached summary
284
+ updateLearningSummary(projectId, config);
285
+
286
+ // Write to Neo4j graph if enabled (async, fire-and-forget)
287
+ if (config?.learningsNeo4j) {
288
+ writeToGraph(projectId, runDbId, report, config, suiteName).catch(() => {});
289
+ }
290
+ }
291
+
292
+ // ── Summary cache ─────────────────────────────────────────────────────────────
293
+
294
+ function updateLearningSummary(projectId, config) {
295
+ const d = getDb();
296
+ const days = config?.learningsDays || 30;
297
+ const cutoff = `datetime('now', '-${days} days')`;
298
+
299
+ // Total runs and tests
300
+ const stats = d.prepare(`
301
+ SELECT COUNT(DISTINCT run_id) AS total_runs,
302
+ COUNT(*) AS total_tests,
303
+ AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END) AS pass_rate,
304
+ AVG(duration_ms) AS avg_duration
305
+ FROM test_learnings
306
+ WHERE project_id = ? AND created_at >= ${cutoff}
307
+ `).get(projectId);
308
+
309
+ // Flaky tests
310
+ const flakyTests = d.prepare(`
311
+ SELECT test_name,
312
+ ROUND(AVG(flaky) * 100, 1) AS flaky_rate,
313
+ COUNT(*) AS total_runs
314
+ FROM test_learnings
315
+ WHERE project_id = ? AND created_at >= ${cutoff}
316
+ GROUP BY test_name
317
+ HAVING flaky_rate > 0
318
+ ORDER BY flaky_rate DESC
319
+ LIMIT 20
320
+ `).all(projectId);
321
+
322
+ // Slow tests (above average)
323
+ const slowTests = d.prepare(`
324
+ SELECT test_name,
325
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
326
+ MAX(duration_ms) AS max_duration_ms
327
+ FROM test_learnings
328
+ WHERE project_id = ? AND created_at >= ${cutoff} AND duration_ms IS NOT NULL
329
+ GROUP BY test_name
330
+ HAVING avg_duration_ms > (SELECT AVG(duration_ms) FROM test_learnings WHERE project_id = ? AND created_at >= ${cutoff} AND duration_ms IS NOT NULL) * 1.5
331
+ ORDER BY avg_duration_ms DESC
332
+ LIMIT 20
333
+ `).all(projectId, projectId);
334
+
335
+ // Unstable selectors
336
+ const unstableSelectors = d.prepare(`
337
+ SELECT selector,
338
+ MAX(action_type) AS action_type,
339
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
340
+ COUNT(*) AS total_uses,
341
+ COUNT(DISTINCT test_name) AS used_by_tests,
342
+ MAX(page_url) AS page_url
343
+ FROM selector_learnings
344
+ WHERE project_id = ? AND created_at >= ${cutoff}
345
+ GROUP BY selector
346
+ HAVING fail_rate > 10
347
+ ORDER BY fail_rate DESC
348
+ LIMIT 20
349
+ `).all(projectId);
350
+
351
+ // Failing pages
352
+ const failingPages = d.prepare(`
353
+ SELECT url_path,
354
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
355
+ COUNT(*) AS total_visits,
356
+ SUM(console_errors) AS console_errors,
357
+ SUM(network_errors) AS network_errors
358
+ FROM page_learnings
359
+ WHERE project_id = ? AND created_at >= ${cutoff}
360
+ GROUP BY url_path
361
+ HAVING fail_rate > 0
362
+ ORDER BY fail_rate DESC
363
+ LIMIT 20
364
+ `).all(projectId);
365
+
366
+ // API issues
367
+ const apiIssues = d.prepare(`
368
+ SELECT endpoint,
369
+ ROUND(AVG(CASE WHEN is_error = 1 THEN 100.0 ELSE 0.0 END), 1) AS error_rate,
370
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
371
+ COUNT(*) AS total_calls,
372
+ GROUP_CONCAT(DISTINCT status) AS status_codes
373
+ FROM api_learnings
374
+ WHERE project_id = ? AND created_at >= ${cutoff}
375
+ GROUP BY endpoint
376
+ HAVING error_rate > 5
377
+ ORDER BY error_rate DESC
378
+ LIMIT 20
379
+ `).all(projectId);
380
+
381
+ // Top errors
382
+ const topErrors = d.prepare(`
383
+ SELECT pattern, category, occurrence_count, first_seen, last_seen, example_error AS example_test
384
+ FROM error_patterns
385
+ WHERE project_id = ?
386
+ ORDER BY occurrence_count DESC
387
+ LIMIT 10
388
+ `).all(projectId);
389
+
390
+ d.prepare(`
391
+ INSERT INTO learning_summary (project_id, total_runs, total_tests, overall_pass_rate, avg_duration_ms, flaky_tests, slow_tests, unstable_selectors, failing_pages, api_issues, top_errors, updated_at)
392
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
393
+ ON CONFLICT(project_id) DO UPDATE SET
394
+ total_runs = excluded.total_runs,
395
+ total_tests = excluded.total_tests,
396
+ overall_pass_rate = excluded.overall_pass_rate,
397
+ avg_duration_ms = excluded.avg_duration_ms,
398
+ flaky_tests = excluded.flaky_tests,
399
+ slow_tests = excluded.slow_tests,
400
+ unstable_selectors = excluded.unstable_selectors,
401
+ failing_pages = excluded.failing_pages,
402
+ api_issues = excluded.api_issues,
403
+ top_errors = excluded.top_errors,
404
+ updated_at = datetime('now')
405
+ `).run(
406
+ projectId,
407
+ stats?.total_runs || 0,
408
+ stats?.total_tests || 0,
409
+ stats?.pass_rate || 0,
410
+ stats?.avg_duration || 0,
411
+ JSON.stringify(flakyTests),
412
+ JSON.stringify(slowTests),
413
+ JSON.stringify(unstableSelectors),
414
+ JSON.stringify(failingPages),
415
+ JSON.stringify(apiIssues),
416
+ JSON.stringify(topErrors),
417
+ );
418
+ }