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