@matware/e2e-runner 1.1.1 → 1.2.1

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 (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +475 -307
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +194 -6
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +10 -2
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +273 -18
  17. package/src/ai-generate.js +87 -7
  18. package/src/config.js +28 -0
  19. package/src/dashboard.js +156 -6
  20. package/src/db.js +207 -13
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +448 -18
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +35 -2
  31. package/src/runner.js +120 -46
  32. package/src/verify.js +5 -3
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +964 -378
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Neo4j knowledge graph integration for the learning system.
3
+ *
4
+ * Optional — all operations are no-ops if neo4j-driver is not installed.
5
+ * Enable with learningsNeo4j: true in config.
6
+ *
7
+ * Nodes: Project, Test, Page, Selector, ApiEndpoint, ErrorPattern, Run
8
+ * Relationships: VISITS, USES_SELECTOR, CALLS_API, FAILED_WITH, EXECUTED_IN, SELECTOR_ON
9
+ */
10
+
11
+ let neo4j = null;
12
+ let driver = null;
13
+
14
+ /** Try to load neo4j-driver. Returns false if not available. */
15
+ async function ensureDriver(config) {
16
+ if (driver) return true;
17
+ if (neo4j === false) return false; // already tried and failed
18
+
19
+ try {
20
+ neo4j = (await import('neo4j-driver')).default;
21
+ driver = neo4j.driver(
22
+ config.neo4jBoltUrl || 'bolt://localhost:7687',
23
+ neo4j.auth.basic(config.neo4jUser || 'neo4j', config.neo4jPassword || 'e2erunner')
24
+ );
25
+ // Verify connectivity
26
+ await driver.verifyConnectivity();
27
+ return true;
28
+ } catch {
29
+ neo4j = false;
30
+ driver = null;
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /** Close the Neo4j driver. */
36
+ export async function closeNeo4j() {
37
+ if (driver) {
38
+ await driver.close();
39
+ driver = null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Writes learning data to Neo4j graph.
45
+ * Called from learnFromRun() when learningsNeo4j is enabled.
46
+ * All operations are no-ops if neo4j-driver is not installed or connection fails.
47
+ */
48
+ export async function writeToGraph(projectId, runDbId, report, config, suiteName) {
49
+ if (!config?.learningsNeo4j) return;
50
+ const connected = await ensureDriver(config);
51
+ if (!connected) return;
52
+
53
+ const session = driver.session();
54
+ try {
55
+ const projectName = config.projectName || 'unknown';
56
+ const cwd = config._cwd || process.cwd();
57
+
58
+ // Ensure Project node
59
+ await session.run(
60
+ 'MERGE (p:Project {cwd: $cwd}) SET p.name = $name, p.updatedAt = datetime()',
61
+ { cwd, name: projectName }
62
+ );
63
+
64
+ // Ensure Run node
65
+ await session.run(
66
+ `MERGE (r:Run {dbId: $runDbId})
67
+ SET r.total = $total, r.passed = $passed, r.failed = $failed,
68
+ r.passRate = $passRate, r.duration = $duration, r.suiteName = $suiteName,
69
+ r.createdAt = datetime()
70
+ WITH r
71
+ MATCH (p:Project {cwd: $cwd})
72
+ MERGE (r)-[:EXECUTED_IN]->(p)`,
73
+ {
74
+ runDbId: neo4j.int(runDbId),
75
+ total: neo4j.int(report.summary.total),
76
+ passed: neo4j.int(report.summary.passed),
77
+ failed: neo4j.int(report.summary.failed),
78
+ passRate: report.summary.passRate,
79
+ duration: report.summary.duration,
80
+ suiteName: suiteName || null,
81
+ cwd,
82
+ }
83
+ );
84
+
85
+ for (const result of report.results) {
86
+ // Test node
87
+ await session.run(
88
+ `MERGE (t:Test {name: $name, projectCwd: $cwd})
89
+ SET t.lastSuccess = $success, t.lastDuration = $duration, t.updatedAt = datetime()
90
+ WITH t
91
+ MATCH (r:Run {dbId: $runDbId})
92
+ MERGE (t)-[:EXECUTED_IN]->(r)`,
93
+ {
94
+ name: result.name,
95
+ cwd,
96
+ success: result.success,
97
+ duration: result.endTime && result.startTime
98
+ ? new Date(result.endTime) - new Date(result.startTime)
99
+ : 0,
100
+ runDbId: neo4j.int(runDbId),
101
+ }
102
+ );
103
+
104
+ // Page nodes + VISITS relationships
105
+ if (result.actions) {
106
+ for (const action of result.actions) {
107
+ if ((action.type === 'goto' || action.type === 'navigate') && action.value) {
108
+ let urlPath = action.value;
109
+ try { urlPath = new URL(action.value, 'http://placeholder').pathname; } catch { /* */ }
110
+
111
+ await session.run(
112
+ `MERGE (pg:Page {path: $path, projectCwd: $cwd})
113
+ SET pg.updatedAt = datetime()
114
+ WITH pg
115
+ MATCH (t:Test {name: $testName, projectCwd: $cwd})
116
+ MERGE (t)-[:VISITS]->(pg)`,
117
+ { path: urlPath, cwd, testName: result.name }
118
+ );
119
+ }
120
+
121
+ // Selector nodes + USES_SELECTOR relationships
122
+ if (action.selector) {
123
+ await session.run(
124
+ `MERGE (s:Selector {value: $selector, projectCwd: $cwd})
125
+ SET s.updatedAt = datetime()
126
+ WITH s
127
+ MATCH (t:Test {name: $testName, projectCwd: $cwd})
128
+ MERGE (t)-[:USES_SELECTOR {actionType: $actionType}]->(s)`,
129
+ { selector: action.selector, cwd, testName: result.name, actionType: action.type }
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ // API endpoint nodes + CALLS_API relationships
136
+ if (result.networkLogs?.length) {
137
+ for (const log of result.networkLogs) {
138
+ if (!log.url || !log.method) continue;
139
+ let urlPath;
140
+ try { urlPath = new URL(log.url).pathname; } catch { urlPath = log.url; }
141
+ urlPath = urlPath
142
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:uuid')
143
+ .replace(/\/\d+/g, '/:id');
144
+ const endpoint = `${log.method} ${urlPath}`;
145
+
146
+ await session.run(
147
+ `MERGE (a:ApiEndpoint {endpoint: $endpoint, projectCwd: $cwd})
148
+ SET a.updatedAt = datetime()
149
+ WITH a
150
+ MATCH (t:Test {name: $testName, projectCwd: $cwd})
151
+ MERGE (t)-[:CALLS_API]->(a)`,
152
+ { endpoint, cwd, testName: result.name }
153
+ );
154
+ }
155
+ }
156
+
157
+ // Error pattern nodes + FAILED_WITH relationships
158
+ if (result.error) {
159
+ const normalized = result.error.replace(/\d+ms/g, 'Nms').replace(/"[^"]+"/g, '"..."').slice(0, 200);
160
+ await session.run(
161
+ `MERGE (e:ErrorPattern {pattern: $pattern, projectCwd: $cwd})
162
+ SET e.count = COALESCE(e.count, 0) + 1, e.lastSeen = datetime()
163
+ WITH e
164
+ MATCH (t:Test {name: $testName, projectCwd: $cwd})
165
+ MERGE (t)-[:FAILED_WITH]->(e)`,
166
+ { pattern: normalized, cwd, testName: result.name }
167
+ );
168
+ }
169
+ }
170
+ } finally {
171
+ await session.close();
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Query the graph for relationships — used by e2e_learnings MCP tool.
177
+ * Returns enriched insights about test dependencies, shared selectors, etc.
178
+ */
179
+ export async function queryGraph(config, queryType, params = {}) {
180
+ if (!config?.learningsNeo4j) return null;
181
+ const connected = await ensureDriver(config);
182
+ if (!connected) return null;
183
+
184
+ const session = driver.session();
185
+ const cwd = config._cwd || process.cwd();
186
+
187
+ try {
188
+ switch (queryType) {
189
+ case 'test-dependencies': {
190
+ // Tests that share selectors or pages with a given test
191
+ const result = await session.run(
192
+ `MATCH (t1:Test {name: $testName, projectCwd: $cwd})-[:USES_SELECTOR]->(s)<-[:USES_SELECTOR]-(t2:Test)
193
+ WHERE t1 <> t2
194
+ RETURN DISTINCT t2.name AS related, COLLECT(DISTINCT s.value) AS sharedSelectors
195
+ LIMIT 20`,
196
+ { testName: params.testName, cwd }
197
+ );
198
+ return result.records.map(r => ({
199
+ test: r.get('related'),
200
+ sharedSelectors: r.get('sharedSelectors'),
201
+ }));
202
+ }
203
+
204
+ case 'page-impact': {
205
+ // All tests that visit a given page
206
+ const result = await session.run(
207
+ `MATCH (t:Test {projectCwd: $cwd})-[:VISITS]->(pg:Page {path: $path, projectCwd: $cwd})
208
+ RETURN t.name AS test, t.lastSuccess AS lastSuccess`,
209
+ { path: params.path, cwd }
210
+ );
211
+ return result.records.map(r => ({
212
+ test: r.get('test'),
213
+ lastSuccess: r.get('lastSuccess'),
214
+ }));
215
+ }
216
+
217
+ case 'error-impact': {
218
+ // Tests that failed with a given error pattern
219
+ const result = await session.run(
220
+ `MATCH (t:Test {projectCwd: $cwd})-[:FAILED_WITH]->(e:ErrorPattern)
221
+ WHERE e.pattern CONTAINS $search
222
+ RETURN t.name AS test, e.pattern AS pattern, e.count AS count
223
+ ORDER BY e.count DESC
224
+ LIMIT 20`,
225
+ { search: params.search || '', cwd }
226
+ );
227
+ return result.records.map(r => ({
228
+ test: r.get('test'),
229
+ pattern: r.get('pattern'),
230
+ count: r.get('count')?.toNumber?.() || r.get('count'),
231
+ }));
232
+ }
233
+
234
+ case 'selector-usage': {
235
+ // All tests and pages using a given selector
236
+ const result = await session.run(
237
+ `MATCH (t:Test {projectCwd: $cwd})-[r:USES_SELECTOR]->(s:Selector {value: $selector, projectCwd: $cwd})
238
+ OPTIONAL MATCH (t)-[:VISITS]->(pg:Page)
239
+ RETURN t.name AS test, r.actionType AS action, COLLECT(DISTINCT pg.path) AS pages`,
240
+ { selector: params.selector, cwd }
241
+ );
242
+ return result.records.map(r => ({
243
+ test: r.get('test'),
244
+ action: r.get('action'),
245
+ pages: r.get('pages'),
246
+ }));
247
+ }
248
+
249
+ default:
250
+ return null;
251
+ }
252
+ } finally {
253
+ await session.close();
254
+ }
255
+ }
@@ -0,0 +1,354 @@
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
+ /** Drill-down: history for a specific test. */
300
+ export function getTestHistory(projectId, testName, days = 30) {
301
+ const d = getDb();
302
+ return d.prepare(`
303
+ SELECT
304
+ tl.test_name,
305
+ tl.success,
306
+ tl.duration_ms,
307
+ tl.flaky,
308
+ tl.attempt,
309
+ tl.error_pattern,
310
+ tl.created_at,
311
+ r.run_id
312
+ FROM test_learnings tl
313
+ LEFT JOIN runs r ON r.id = tl.run_id
314
+ WHERE tl.project_id = ? AND tl.test_name = ? AND tl.created_at >= datetime('now', '-' || ? || ' days')
315
+ ORDER BY tl.created_at DESC
316
+ `).all(projectId, testName, days);
317
+ }
318
+
319
+ /** Drill-down: history for a specific page. */
320
+ export function getPageHistory(projectId, urlPath, days = 30) {
321
+ const d = getDb();
322
+ return d.prepare(`
323
+ SELECT
324
+ url_path,
325
+ success,
326
+ load_time_ms,
327
+ console_errors,
328
+ console_warns,
329
+ network_errors,
330
+ test_name,
331
+ created_at
332
+ FROM page_learnings
333
+ WHERE project_id = ? AND url_path = ? AND created_at >= datetime('now', '-' || ? || ' days')
334
+ ORDER BY created_at DESC
335
+ `).all(projectId, urlPath, days);
336
+ }
337
+
338
+ /** Drill-down: history for a specific selector. */
339
+ export function getSelectorHistory(projectId, selector, days = 30) {
340
+ const d = getDb();
341
+ return d.prepare(`
342
+ SELECT
343
+ selector,
344
+ action_type,
345
+ success,
346
+ page_url,
347
+ test_name,
348
+ error,
349
+ created_at
350
+ FROM selector_learnings
351
+ WHERE project_id = ? AND selector = ? AND created_at >= datetime('now', '-' || ? || ' days')
352
+ ORDER BY created_at DESC
353
+ `).all(projectId, selector, days);
354
+ }