@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,658 @@
1
+ /**
2
+ * Learning system — SQLite read queries.
3
+ *
4
+ * All functions return plain objects/arrays ready for JSON serialization.
5
+ * Used by MCP tools, dashboard REST endpoints, CLI, and markdown generator.
6
+ */
7
+
8
+ import { getDb } from './db.js';
9
+
10
+ /**
11
+ * Full learning summary for a project — reads from the cached learning_summary table.
12
+ * Falls back to live queries if cache is empty.
13
+ */
14
+ export function getLearningsSummary(projectId) {
15
+ const d = getDb();
16
+ const empty = {
17
+ totalRuns: 0, totalTests: 0, overallPassRate: 0, avgDurationMs: 0,
18
+ flakyTests: [], slowTests: [], unstableSelectors: [],
19
+ failingPages: [], apiIssues: [], topErrors: [], updatedAt: null,
20
+ };
21
+
22
+ // Cross-project aggregate when projectId is null
23
+ if (projectId === null || projectId === undefined) {
24
+ const rows = d.prepare('SELECT * FROM learning_summary').all();
25
+ if (!rows.length) return empty;
26
+ let totalRuns = 0, totalTests = 0, passSumW = 0, durSumW = 0;
27
+ let allFlaky = [], allSlow = [], allSelectors = [], allPages = [], allApis = [], allErrors = [];
28
+ let latestUpdate = null;
29
+ for (const row of rows) {
30
+ totalRuns += row.total_runs;
31
+ totalTests += row.total_tests;
32
+ passSumW += row.overall_pass_rate * row.total_tests;
33
+ durSumW += row.avg_duration_ms * row.total_tests;
34
+ allFlaky = allFlaky.concat(JSON.parse(row.flaky_tests || '[]'));
35
+ allSlow = allSlow.concat(JSON.parse(row.slow_tests || '[]'));
36
+ allSelectors = allSelectors.concat(JSON.parse(row.unstable_selectors || '[]'));
37
+ allPages = allPages.concat(JSON.parse(row.failing_pages || '[]'));
38
+ allApis = allApis.concat(JSON.parse(row.api_issues || '[]'));
39
+ allErrors = allErrors.concat(JSON.parse(row.top_errors || '[]'));
40
+ if (!latestUpdate || (row.updated_at && row.updated_at > latestUpdate)) latestUpdate = row.updated_at;
41
+ }
42
+ return {
43
+ totalRuns, totalTests,
44
+ overallPassRate: totalTests > 0 ? Math.round(passSumW / totalTests * 10) / 10 : 0,
45
+ avgDurationMs: totalTests > 0 ? Math.round(durSumW / totalTests) : 0,
46
+ flakyTests: allFlaky, slowTests: allSlow, unstableSelectors: allSelectors,
47
+ failingPages: allPages, apiIssues: allApis, topErrors: allErrors, updatedAt: latestUpdate,
48
+ };
49
+ }
50
+
51
+ const row = d.prepare('SELECT * FROM learning_summary WHERE project_id = ?').get(projectId);
52
+ if (!row) return empty;
53
+
54
+ return {
55
+ totalRuns: row.total_runs,
56
+ totalTests: row.total_tests,
57
+ overallPassRate: Math.round(row.overall_pass_rate * 10) / 10,
58
+ avgDurationMs: Math.round(row.avg_duration_ms),
59
+ flakyTests: JSON.parse(row.flaky_tests || '[]'),
60
+ slowTests: JSON.parse(row.slow_tests || '[]'),
61
+ unstableSelectors: JSON.parse(row.unstable_selectors || '[]'),
62
+ failingPages: JSON.parse(row.failing_pages || '[]'),
63
+ apiIssues: JSON.parse(row.api_issues || '[]'),
64
+ topErrors: JSON.parse(row.top_errors || '[]'),
65
+ updatedAt: row.updated_at,
66
+ };
67
+ }
68
+
69
+ /** Flaky test details — tests that pass only after retries. */
70
+ export function getFlakySummary(projectId, days = 30) {
71
+ const d = getDb();
72
+ return d.prepare(`
73
+ SELECT
74
+ test_name,
75
+ COUNT(*) AS total_runs,
76
+ SUM(flaky) AS flaky_count,
77
+ ROUND(AVG(flaky) * 100, 1) AS flaky_rate,
78
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
79
+ MAX(CASE WHEN flaky = 1 THEN created_at END) AS last_flaky,
80
+ ROUND(AVG(attempt), 1) AS avg_attempts
81
+ FROM test_learnings
82
+ WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
83
+ GROUP BY test_name
84
+ HAVING flaky_count > 0
85
+ ORDER BY flaky_rate DESC
86
+ `).all(projectId, days);
87
+ }
88
+
89
+ /** Selector stability — selectors with failure rates. */
90
+ export function getSelectorStability(projectId, days = 30) {
91
+ const d = getDb();
92
+ return d.prepare(`
93
+ SELECT
94
+ selector,
95
+ action_type,
96
+ COUNT(*) AS total_uses,
97
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS fail_count,
98
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
99
+ COUNT(DISTINCT test_name) AS used_by_tests,
100
+ page_url,
101
+ MAX(CASE WHEN success = 0 THEN error END) AS last_error
102
+ FROM selector_learnings
103
+ WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
104
+ GROUP BY selector, action_type
105
+ HAVING fail_rate > 0
106
+ ORDER BY fail_rate DESC
107
+ `).all(projectId, days);
108
+ }
109
+
110
+ /** Page health — pages with failure rates, console/network errors. */
111
+ export function getPageHealth(projectId, days = 30) {
112
+ const d = getDb();
113
+ return d.prepare(`
114
+ SELECT
115
+ url_path,
116
+ COUNT(*) AS total_visits,
117
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS fail_count,
118
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
119
+ COUNT(DISTINCT test_name) AS tested_by,
120
+ SUM(console_errors) AS console_errors,
121
+ SUM(console_warns) AS console_warns,
122
+ SUM(network_errors) AS network_errors,
123
+ ROUND(AVG(load_time_ms)) AS avg_load_ms
124
+ FROM page_learnings
125
+ WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
126
+ GROUP BY url_path
127
+ ORDER BY fail_rate DESC
128
+ `).all(projectId, days);
129
+ }
130
+
131
+ /** API health — endpoints with error rates and latency. */
132
+ export function getApiHealth(projectId, days = 30) {
133
+ const d = getDb();
134
+ return d.prepare(`
135
+ SELECT
136
+ endpoint,
137
+ COUNT(*) AS total_calls,
138
+ SUM(is_error) AS error_count,
139
+ ROUND(AVG(CASE WHEN is_error = 1 THEN 100.0 ELSE 0.0 END), 1) AS error_rate,
140
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
141
+ MAX(duration_ms) AS max_duration_ms,
142
+ GROUP_CONCAT(DISTINCT status) AS status_codes
143
+ FROM api_learnings
144
+ WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
145
+ GROUP BY endpoint
146
+ ORDER BY error_rate DESC, total_calls DESC
147
+ `).all(projectId, days);
148
+ }
149
+
150
+ /** Error patterns — most frequent errors with categories. */
151
+ export function getErrorPatterns(projectId) {
152
+ const d = getDb();
153
+ return d.prepare(`
154
+ SELECT
155
+ pattern,
156
+ category,
157
+ occurrence_count,
158
+ first_seen,
159
+ last_seen,
160
+ example_error,
161
+ example_test
162
+ FROM error_patterns
163
+ WHERE project_id = ?
164
+ ORDER BY occurrence_count DESC
165
+ `).all(projectId);
166
+ }
167
+
168
+ /** Test pass/fail trends over time — aggregated by day, or by hour when all data is from a single day. */
169
+ export function getTestTrends(projectId, days = 7) {
170
+ const d = getDb();
171
+ const projectClause = (projectId !== null && projectId !== undefined) ? 'project_id = ? AND' : '';
172
+ const params = (projectId !== null && projectId !== undefined) ? [projectId, days] : [days];
173
+
174
+ const daily = d.prepare(`
175
+ SELECT
176
+ DATE(created_at) AS date,
177
+ COUNT(*) AS total_tests,
178
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS passed,
179
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed,
180
+ ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate,
181
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
182
+ SUM(flaky) AS flaky_count
183
+ FROM test_learnings
184
+ WHERE ${projectClause} created_at >= datetime('now', '-' || ? || ' days')
185
+ GROUP BY DATE(created_at)
186
+ ORDER BY date ASC
187
+ `).all(...params);
188
+
189
+ // If all data is from a single day, provide hourly breakdown instead
190
+ if (daily.length <= 1 && daily[0]?.total_tests > 1) {
191
+ const hourly = d.prepare(`
192
+ SELECT
193
+ strftime('%Y-%m-%d %H:00', created_at) AS date,
194
+ COUNT(*) AS total_tests,
195
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS passed,
196
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed,
197
+ ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate,
198
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
199
+ SUM(flaky) AS flaky_count
200
+ FROM test_learnings
201
+ WHERE ${projectClause} created_at >= datetime('now', '-' || ? || ' days')
202
+ GROUP BY strftime('%Y-%m-%d %H', created_at)
203
+ ORDER BY date ASC
204
+ `).all(...params);
205
+ if (hourly.length > 1) {
206
+ return { granularity: 'hourly', data: hourly };
207
+ }
208
+ }
209
+
210
+ return { granularity: 'daily', data: daily };
211
+ }
212
+
213
+ /**
214
+ * Contextual insights for the current run — identifies known flaky tests,
215
+ * new failures, recovered tests, and unstable selectors used.
216
+ */
217
+ export function getRunInsights(projectId, report) {
218
+ const d = getDb();
219
+ const insights = [];
220
+
221
+ if (!report?.results) return insights;
222
+
223
+ for (const result of report.results) {
224
+ const history = d.prepare(`
225
+ SELECT
226
+ COUNT(*) AS total_runs,
227
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS pass_count,
228
+ SUM(flaky) AS flaky_count,
229
+ ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate
230
+ FROM test_learnings
231
+ WHERE project_id = ? AND test_name = ?
232
+ `).get(projectId, result.name);
233
+
234
+ if (!history || history.total_runs === 0) continue;
235
+
236
+ // Known flaky test
237
+ if (history.flaky_count > 0 && result.success) {
238
+ insights.push({
239
+ type: 'flaky',
240
+ test: result.name,
241
+ message: `Known flaky test (${history.flaky_count} flaky runs out of ${history.total_runs}). Passed this time.`,
242
+ });
243
+ }
244
+
245
+ // New failure (was passing, now fails)
246
+ if (!result.success && history.pass_rate > 80) {
247
+ insights.push({
248
+ type: 'new-failure',
249
+ test: result.name,
250
+ message: `New failure — this test had ${history.pass_rate}% pass rate over ${history.total_runs} runs.`,
251
+ });
252
+ }
253
+
254
+ // Recovered (was failing, now passes)
255
+ if (result.success && history.pass_rate < 50 && history.total_runs >= 3) {
256
+ insights.push({
257
+ type: 'recovered',
258
+ test: result.name,
259
+ message: `Recovered — was failing (${history.pass_rate}% pass rate over ${history.total_runs} runs).`,
260
+ });
261
+ }
262
+ }
263
+
264
+ // Check for unstable selectors used in this run
265
+ const selectorStats = d.prepare(`
266
+ SELECT selector, ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate
267
+ FROM selector_learnings
268
+ WHERE project_id = ?
269
+ GROUP BY selector
270
+ HAVING fail_rate > 20
271
+ `).all(projectId);
272
+
273
+ if (selectorStats.length > 0) {
274
+ const selectorSet = new Set(selectorStats.map(s => s.selector));
275
+ const usedUnstable = [];
276
+
277
+ for (const result of report.results) {
278
+ if (!result.actions) continue;
279
+ for (const action of result.actions) {
280
+ if (action.selector && selectorSet.has(action.selector)) {
281
+ usedUnstable.push(action.selector);
282
+ }
283
+ }
284
+ }
285
+
286
+ const unique = [...new Set(usedUnstable)];
287
+ if (unique.length > 0) {
288
+ insights.push({
289
+ type: 'unstable-selectors',
290
+ selectors: unique.slice(0, 5),
291
+ message: `${unique.length} unstable selector(s) used in this run: ${unique.slice(0, 3).join(', ')}${unique.length > 3 ? '...' : ''}`,
292
+ });
293
+ }
294
+ }
295
+
296
+ return insights;
297
+ }
298
+
299
+ /**
300
+ * Compact health snapshot for a project — used by CLI, MCP, and Dashboard.
301
+ * Returns null if no historical data exists.
302
+ */
303
+ export function getHealthSnapshot(projectId) {
304
+ const summary = getLearningsSummary(projectId);
305
+ if (!summary || summary.totalRuns === 0) return null;
306
+
307
+ const flakyCount = summary.flakyTests ? summary.flakyTests.length : 0;
308
+ const unstableSelectorCount = summary.unstableSelectors ? summary.unstableSelectors.length : 0;
309
+ const topError = summary.topErrors && summary.topErrors.length > 0
310
+ ? { pattern: summary.topErrors[0].pattern, count: summary.topErrors[0].occurrence_count, category: summary.topErrors[0].category }
311
+ : null;
312
+
313
+ // Compute trend from recent daily data
314
+ let passRateTrend = 'stable'; // 'improving', 'declining', 'stable'
315
+ let trendDelta = 0;
316
+
317
+ const trends = getTestTrends(projectId, 7);
318
+ const trendData = trends?.data || trends || [];
319
+ if (Array.isArray(trendData) && trendData.length >= 2) {
320
+ const recent = trendData[trendData.length - 1].pass_rate;
321
+ const prior = trendData.slice(0, -1).reduce((s, t) => s + t.pass_rate, 0) / (trendData.length - 1);
322
+ trendDelta = Math.round((recent - prior) * 10) / 10;
323
+ if (trendDelta > 2) passRateTrend = 'improving';
324
+ else if (trendDelta < -2) passRateTrend = 'declining';
325
+ }
326
+
327
+ return {
328
+ passRate: summary.overallPassRate,
329
+ passRateTrend,
330
+ trendDelta,
331
+ flakyCount,
332
+ unstableSelectorCount,
333
+ topErrorPattern: topError,
334
+ totalRuns: summary.totalRuns,
335
+ totalTests: summary.totalTests,
336
+ };
337
+ }
338
+
339
+ /** Drill-down: history for a specific test. */
340
+ export function getTestHistory(projectId, testName, days = 30) {
341
+ const d = getDb();
342
+ return d.prepare(`
343
+ SELECT
344
+ tl.test_name,
345
+ tl.success,
346
+ tl.duration_ms,
347
+ tl.flaky,
348
+ tl.attempt,
349
+ tl.error_pattern,
350
+ tl.created_at,
351
+ r.run_id
352
+ FROM test_learnings tl
353
+ LEFT JOIN runs r ON r.id = tl.run_id
354
+ WHERE tl.project_id = ? AND tl.test_name = ? AND tl.created_at >= datetime('now', '-' || ? || ' days')
355
+ ORDER BY tl.created_at DESC
356
+ `).all(projectId, testName, days);
357
+ }
358
+
359
+ /** Drill-down: history for a specific page. */
360
+ export function getPageHistory(projectId, urlPath, days = 30) {
361
+ const d = getDb();
362
+ return d.prepare(`
363
+ SELECT
364
+ url_path,
365
+ success,
366
+ load_time_ms,
367
+ console_errors,
368
+ console_warns,
369
+ network_errors,
370
+ test_name,
371
+ created_at
372
+ FROM page_learnings
373
+ WHERE project_id = ? AND url_path = ? AND created_at >= datetime('now', '-' || ? || ' days')
374
+ ORDER BY created_at DESC
375
+ `).all(projectId, urlPath, days);
376
+ }
377
+
378
+ /** Drill-down: history for a specific selector. */
379
+ export function getSelectorHistory(projectId, selector, days = 30) {
380
+ const d = getDb();
381
+ return d.prepare(`
382
+ SELECT
383
+ selector,
384
+ action_type,
385
+ success,
386
+ page_url,
387
+ test_name,
388
+ error,
389
+ created_at
390
+ FROM selector_learnings
391
+ WHERE project_id = ? AND selector = ? AND created_at >= datetime('now', '-' || ? || ' days')
392
+ ORDER BY created_at DESC
393
+ `).all(projectId, selector, days);
394
+ }
395
+
396
+ /**
397
+ * Aggregated context for test authoring — curates the most actionable learnings
398
+ * into a compact object that AI agents can use to write better tests.
399
+ */
400
+ export function getTestCreationContext(projectId) {
401
+ const d = getDb();
402
+ const ctx = {};
403
+
404
+ // Top 5 unstable selectors (>20% fail rate)
405
+ const unstable = d.prepare(`
406
+ SELECT
407
+ selector,
408
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
409
+ MAX(CASE WHEN success = 0 THEN error END) AS last_error,
410
+ COUNT(*) AS total_uses
411
+ FROM selector_learnings
412
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
413
+ GROUP BY selector
414
+ HAVING fail_rate > 20
415
+ ORDER BY fail_rate DESC
416
+ LIMIT 5
417
+ `).all(projectId);
418
+
419
+ if (unstable.length > 0) {
420
+ ctx.unstableSelectors = unstable.map(s => ({
421
+ selector: s.selector,
422
+ failRate: s.fail_rate,
423
+ lastError: s.last_error,
424
+ suggestion: suggestSelectorFix(s.selector),
425
+ }));
426
+ }
427
+
428
+ // Top 10 stable selectors (0% fail rate, >5 uses)
429
+ const stable = d.prepare(`
430
+ SELECT
431
+ selector,
432
+ COUNT(*) AS total_uses,
433
+ COUNT(DISTINCT test_name) AS used_by_tests
434
+ FROM selector_learnings
435
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
436
+ GROUP BY selector
437
+ HAVING total_uses > 5 AND SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0
438
+ ORDER BY total_uses DESC
439
+ LIMIT 10
440
+ `).all(projectId);
441
+
442
+ if (stable.length > 0) {
443
+ ctx.stableSelectors = stable.map(s => ({
444
+ selector: s.selector,
445
+ uses: s.total_uses,
446
+ tests: s.used_by_tests,
447
+ }));
448
+ }
449
+
450
+ // Top 5 error patterns
451
+ const errors = d.prepare(`
452
+ SELECT pattern, category, occurrence_count
453
+ FROM error_patterns
454
+ WHERE project_id = ?
455
+ ORDER BY occurrence_count DESC
456
+ LIMIT 5
457
+ `).all(projectId);
458
+
459
+ if (errors.length > 0) {
460
+ ctx.errorPatterns = errors.map(e => ({
461
+ pattern: e.pattern,
462
+ category: e.category,
463
+ count: e.occurrence_count,
464
+ }));
465
+ }
466
+
467
+ // Slow pages (avg load > 3s)
468
+ const slowPages = d.prepare(`
469
+ SELECT
470
+ url_path,
471
+ ROUND(AVG(load_time_ms)) AS avg_load_ms
472
+ FROM page_learnings
473
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
474
+ GROUP BY url_path
475
+ HAVING avg_load_ms > 3000
476
+ ORDER BY avg_load_ms DESC
477
+ LIMIT 5
478
+ `).all(projectId);
479
+
480
+ if (slowPages.length > 0) {
481
+ ctx.slowPages = slowPages.map(p => ({
482
+ page: p.url_path,
483
+ avgLoadMs: p.avg_load_ms,
484
+ }));
485
+ }
486
+
487
+ // Flaky tests
488
+ const flaky = d.prepare(`
489
+ SELECT test_name, SUM(flaky) AS flaky_count, COUNT(*) AS total_runs
490
+ FROM test_learnings
491
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
492
+ GROUP BY test_name
493
+ HAVING flaky_count > 0
494
+ ORDER BY flaky_count DESC
495
+ LIMIT 5
496
+ `).all(projectId);
497
+
498
+ if (flaky.length > 0) {
499
+ ctx.flakyTests = flaky.map(f => ({
500
+ name: f.test_name,
501
+ flakyCount: f.flaky_count,
502
+ totalRuns: f.total_runs,
503
+ }));
504
+ }
505
+
506
+ // API endpoints with >10% error rate
507
+ const apiIssues = d.prepare(`
508
+ SELECT
509
+ endpoint,
510
+ ROUND(AVG(CASE WHEN is_error = 1 THEN 100.0 ELSE 0.0 END), 1) AS error_rate,
511
+ COUNT(*) AS total_calls
512
+ FROM api_learnings
513
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
514
+ GROUP BY endpoint
515
+ HAVING error_rate > 10
516
+ ORDER BY error_rate DESC
517
+ LIMIT 5
518
+ `).all(projectId);
519
+
520
+ if (apiIssues.length > 0) {
521
+ ctx.apiIssues = apiIssues.map(a => ({
522
+ endpoint: a.endpoint,
523
+ errorRate: a.error_rate,
524
+ totalCalls: a.total_calls,
525
+ }));
526
+ }
527
+
528
+ // Overall pass rate
529
+ const stats = d.prepare(`
530
+ SELECT
531
+ COUNT(*) AS total_tests,
532
+ ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate
533
+ FROM test_learnings
534
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
535
+ `).get(projectId);
536
+
537
+ if (stats && stats.total_tests > 0) {
538
+ ctx.passRate = stats.pass_rate;
539
+ }
540
+
541
+ return Object.keys(ctx).length > 0 ? ctx : null;
542
+ }
543
+
544
+ /** Suggest a fix for an unstable selector based on its pattern. */
545
+ function suggestSelectorFix(selector) {
546
+ if (/^\.Mui|^\.css-|^\.sc-/.test(selector)) return 'Prefer [data-testid] or click by text — generated class names are brittle';
547
+ if (/\s>\s/.test(selector) && selector.split('>').length > 3) return 'Deeply nested selector — simplify or use [data-testid]';
548
+ if (/nth-child|nth-of-type/.test(selector)) return 'Positional selector — prefer [data-testid] or text-based selection';
549
+ return 'Consider using [data-testid] or a more stable selector';
550
+ }
551
+
552
+ /**
553
+ * Cross-reference a run report with historical learnings to produce actionable
554
+ * improvement suggestions for the AI agent.
555
+ */
556
+ export function generateImprovements(projectId, report) {
557
+ const d = getDb();
558
+ const improvements = [];
559
+
560
+ if (!report?.results) return improvements;
561
+
562
+ // Build a map of stable alternatives for unstable selectors
563
+ const stableAlts = d.prepare(`
564
+ SELECT selector, COUNT(*) AS uses
565
+ FROM selector_learnings
566
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
567
+ GROUP BY selector
568
+ HAVING uses > 3 AND SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0
569
+ ORDER BY uses DESC
570
+ `).all(projectId);
571
+
572
+ const stableSet = new Set(stableAlts.map(s => s.selector));
573
+
574
+ // Unstable selectors with their fail rates
575
+ const unstableMap = new Map();
576
+ const unstableRows = d.prepare(`
577
+ SELECT
578
+ selector,
579
+ ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate
580
+ FROM selector_learnings
581
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
582
+ GROUP BY selector
583
+ HAVING fail_rate > 20
584
+ `).all(projectId);
585
+ for (const row of unstableRows) unstableMap.set(row.selector, row.fail_rate);
586
+
587
+ // Flaky test counts
588
+ const flakyMap = new Map();
589
+ const flakyRows = d.prepare(`
590
+ SELECT test_name, SUM(flaky) AS flaky_count
591
+ FROM test_learnings
592
+ WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
593
+ GROUP BY test_name
594
+ HAVING flaky_count > 0
595
+ `).all(projectId);
596
+ for (const row of flakyRows) flakyMap.set(row.test_name, row.flaky_count);
597
+
598
+ for (const result of report.results) {
599
+ // Failed selector suggestions — find stable alternatives on the same page
600
+ if (!result.success && result.error) {
601
+ const selectorMatch = result.error.match(/selector ["']([^"']+)["']/i)
602
+ || result.error.match(/waiting for selector (.+)/i);
603
+ if (selectorMatch) {
604
+ const failedSelector = selectorMatch[1];
605
+ const failRate = unstableMap.get(failedSelector);
606
+ if (failRate) {
607
+ improvements.push({
608
+ type: 'unstable-selector',
609
+ test: result.name,
610
+ message: `Selector \`${failedSelector}\` failed (${failRate}% historical fail rate) → ${suggestSelectorFix(failedSelector)}`,
611
+ });
612
+ }
613
+ }
614
+
615
+ // Timeout suggestions
616
+ if (/timeout|timed?\s*out/i.test(result.error)) {
617
+ improvements.push({
618
+ type: 'timeout',
619
+ test: result.name,
620
+ message: `Test "${result.name}" timed out → add explicit { type: "wait", text: "..." } or increase timeout`,
621
+ });
622
+ }
623
+ }
624
+
625
+ // Check for tests using known unstable selectors (even if they passed this time)
626
+ if (result.actions) {
627
+ for (const action of result.actions) {
628
+ if (action.selector && unstableMap.has(action.selector)) {
629
+ const failRate = unstableMap.get(action.selector);
630
+ improvements.push({
631
+ type: 'at-risk-selector',
632
+ test: result.name,
633
+ message: `Selector \`${action.selector}\` has ${failRate}% fail rate → ${suggestSelectorFix(action.selector)}`,
634
+ });
635
+ }
636
+ }
637
+ }
638
+
639
+ // Flaky test suggestions
640
+ const flakyCount = flakyMap.get(result.name);
641
+ if (flakyCount && flakyCount >= 2) {
642
+ improvements.push({
643
+ type: 'flaky',
644
+ test: result.name,
645
+ message: `Test "${result.name}" is flaky (${flakyCount} flaky runs) → add { retries: 2 } to the test config`,
646
+ });
647
+ }
648
+ }
649
+
650
+ // Deduplicate by type+test (keep first occurrence)
651
+ const seen = new Set();
652
+ return improvements.filter(imp => {
653
+ const key = `${imp.type}:${imp.test}:${imp.message.slice(0, 60)}`;
654
+ if (seen.has(key)) return false;
655
+ seen.add(key);
656
+ return true;
657
+ });
658
+ }