@matware/e2e-runner 1.1.0 → 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.
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +505 -279
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +275 -7
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +11 -3
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +280 -17
- package/src/ai-generate.js +122 -11
- package/src/config.js +58 -0
- package/src/dashboard.js +173 -10
- package/src/db.js +232 -17
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +575 -16
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +47 -2
- package/src/runner.js +180 -40
- package/src/verify.js +19 -5
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +1091 -268
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
package/src/dashboard.js
CHANGED
|
@@ -17,9 +17,10 @@ import { createWebSocketServer } from './websocket.js';
|
|
|
17
17
|
import { getPoolStatus, waitForPool } from './pool.js';
|
|
18
18
|
import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
|
|
19
19
|
import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.js';
|
|
20
|
-
import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, closeDb } from './db.js';
|
|
20
|
+
import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, closeDb } from './db.js';
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { log, colors as C } from './logger.js';
|
|
23
|
+
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from './learner-sqlite.js';
|
|
23
24
|
|
|
24
25
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
25
26
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -204,6 +205,27 @@ export async function startDashboard(config) {
|
|
|
204
205
|
return;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// API: DB — network logs for a run (filterable)
|
|
209
|
+
const networkLogsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/network-logs$/);
|
|
210
|
+
if (networkLogsMatch) {
|
|
211
|
+
try {
|
|
212
|
+
const runDbId = parseInt(networkLogsMatch[1], 10);
|
|
213
|
+
const filters = {};
|
|
214
|
+
if (url.searchParams.has('testName')) filters.testName = url.searchParams.get('testName');
|
|
215
|
+
if (url.searchParams.has('method')) filters.method = url.searchParams.get('method');
|
|
216
|
+
if (url.searchParams.has('statusMin')) filters.statusMin = parseInt(url.searchParams.get('statusMin'), 10);
|
|
217
|
+
if (url.searchParams.has('statusMax')) filters.statusMax = parseInt(url.searchParams.get('statusMax'), 10);
|
|
218
|
+
if (url.searchParams.has('urlPattern')) filters.urlPattern = url.searchParams.get('urlPattern');
|
|
219
|
+
if (url.searchParams.get('errorsOnly') === 'true') filters.errorsOnly = true;
|
|
220
|
+
if (url.searchParams.get('includeHeaders') === 'true') filters.includeHeaders = true;
|
|
221
|
+
if (url.searchParams.get('includeBodies') === 'true') filters.includeBodies = true;
|
|
222
|
+
jsonResponse(res, dbGetNetworkLogs(runDbId, filters));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
// API: DB — project screenshots list
|
|
208
230
|
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
209
231
|
if (projectScreenshotsMatch) {
|
|
@@ -239,6 +261,124 @@ export async function startDashboard(config) {
|
|
|
239
261
|
return;
|
|
240
262
|
}
|
|
241
263
|
|
|
264
|
+
// API: DB — suite detail (tests + actions)
|
|
265
|
+
const suiteDetailMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites\/(.+)$/);
|
|
266
|
+
if (suiteDetailMatch) {
|
|
267
|
+
try {
|
|
268
|
+
const projectId = parseInt(suiteDetailMatch[1], 10);
|
|
269
|
+
const suiteName = decodeURIComponent(suiteDetailMatch[2]);
|
|
270
|
+
const dir = dbGetProjectTestsDir(projectId);
|
|
271
|
+
if (!dir || !fs.existsSync(dir)) {
|
|
272
|
+
jsonResponse(res, { error: 'Tests directory not found' }, 404);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const { tests, hooks } = loadTestSuite(suiteName, dir);
|
|
276
|
+
jsonResponse(res, { name: suiteName, tests, hooks });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// API: DB — cross-project learnings (when no project selected)
|
|
284
|
+
const crossLearningsMatch = pathname.match(/^\/api\/db\/learnings(?:\/(\w+))?$/);
|
|
285
|
+
if (crossLearningsMatch) {
|
|
286
|
+
try {
|
|
287
|
+
const category = crossLearningsMatch[1] || 'summary';
|
|
288
|
+
const days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
289
|
+
let data;
|
|
290
|
+
switch (category) {
|
|
291
|
+
case 'summary': {
|
|
292
|
+
const summary = getLearningsSummary(null);
|
|
293
|
+
const trends = getTestTrends(null, 7);
|
|
294
|
+
data = { ...summary, recentTrend: trends };
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
jsonResponse(res, data);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// API: DB — project modules list
|
|
309
|
+
const projectModulesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/modules$/);
|
|
310
|
+
if (projectModulesMatch) {
|
|
311
|
+
try {
|
|
312
|
+
const projectId = parseInt(projectModulesMatch[1], 10);
|
|
313
|
+
const projectCwd = dbGetProjectCwd(projectId);
|
|
314
|
+
if (!projectCwd) { jsonResponse(res, []); return; }
|
|
315
|
+
const modulesDir = path.join(projectCwd, 'e2e', 'modules');
|
|
316
|
+
if (!fs.existsSync(modulesDir)) { jsonResponse(res, []); return; }
|
|
317
|
+
const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
|
|
318
|
+
const modules = files.map(f => {
|
|
319
|
+
try {
|
|
320
|
+
const data = JSON.parse(fs.readFileSync(path.join(modulesDir, f), 'utf-8'));
|
|
321
|
+
return {
|
|
322
|
+
name: f.replace('.json', ''),
|
|
323
|
+
file: f,
|
|
324
|
+
description: data.description || null,
|
|
325
|
+
params: data.params || [],
|
|
326
|
+
actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
|
|
327
|
+
};
|
|
328
|
+
} catch { return { name: f.replace('.json', ''), file: f, description: null, params: [], actionCount: 0 }; }
|
|
329
|
+
});
|
|
330
|
+
jsonResponse(res, modules);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// API: DB — project learnings (summary or specific category)
|
|
338
|
+
const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
|
|
339
|
+
if (learningsMatch) {
|
|
340
|
+
try {
|
|
341
|
+
const projectId = parseInt(learningsMatch[1], 10);
|
|
342
|
+
const category = learningsMatch[2] || 'summary';
|
|
343
|
+
const days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
344
|
+
|
|
345
|
+
let data;
|
|
346
|
+
switch (category) {
|
|
347
|
+
case 'summary': {
|
|
348
|
+
const summary = getLearningsSummary(projectId);
|
|
349
|
+
const trends = getTestTrends(projectId, 7);
|
|
350
|
+
data = { ...summary, recentTrend: trends };
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'flaky':
|
|
354
|
+
data = getFlakySummary(projectId, days);
|
|
355
|
+
break;
|
|
356
|
+
case 'selectors':
|
|
357
|
+
data = getSelectorStability(projectId, days);
|
|
358
|
+
break;
|
|
359
|
+
case 'pages':
|
|
360
|
+
data = getPageHealth(projectId, days);
|
|
361
|
+
break;
|
|
362
|
+
case 'apis':
|
|
363
|
+
data = getApiHealth(projectId, days);
|
|
364
|
+
break;
|
|
365
|
+
case 'errors':
|
|
366
|
+
data = getErrorPatterns(projectId);
|
|
367
|
+
break;
|
|
368
|
+
case 'trends':
|
|
369
|
+
data = getTestTrends(projectId, days);
|
|
370
|
+
break;
|
|
371
|
+
default:
|
|
372
|
+
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
jsonResponse(res, data);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
242
382
|
// API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
|
|
243
383
|
const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
|
|
244
384
|
if (ssHashMatch) {
|
|
@@ -262,7 +402,7 @@ export async function startDashboard(config) {
|
|
|
262
402
|
const ext = path.extname(realPath).toLowerCase();
|
|
263
403
|
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
264
404
|
if (!mimeTypes[ext]) { jsonResponse(res, { error: 'Not an image' }, 400); return; }
|
|
265
|
-
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
405
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
266
406
|
fs.createReadStream(realPath).pipe(res);
|
|
267
407
|
} catch (error) {
|
|
268
408
|
jsonResponse(res, { error: error.message }, 500);
|
|
@@ -302,7 +442,7 @@ export async function startDashboard(config) {
|
|
|
302
442
|
jsonResponse(res, { error: 'Not an image' }, 400);
|
|
303
443
|
return;
|
|
304
444
|
}
|
|
305
|
-
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
445
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
306
446
|
fs.createReadStream(realPath).pipe(res);
|
|
307
447
|
return;
|
|
308
448
|
}
|
|
@@ -347,7 +487,7 @@ export async function startDashboard(config) {
|
|
|
347
487
|
return;
|
|
348
488
|
}
|
|
349
489
|
if (fs.existsSync(resolvedPath)) {
|
|
350
|
-
res.writeHead(200, { 'Content-Type': imageMimeTypes[ext] });
|
|
490
|
+
res.writeHead(200, { 'Content-Type': imageMimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
351
491
|
fs.createReadStream(resolvedPath).pipe(res);
|
|
352
492
|
} else {
|
|
353
493
|
jsonResponse(res, { error: 'Not found' }, 404);
|
|
@@ -422,20 +562,31 @@ export async function startDashboard(config) {
|
|
|
422
562
|
function bufferLiveEvent(data) {
|
|
423
563
|
const rid = data.runId;
|
|
424
564
|
if (!rid) return;
|
|
425
|
-
if (data.event === 'run:start') liveEventBuffers[rid] = [];
|
|
426
|
-
if (!liveEventBuffers[rid]) liveEventBuffers[rid] = [];
|
|
427
|
-
liveEventBuffers[rid].push(data);
|
|
565
|
+
if (data.event === 'run:start') liveEventBuffers[rid] = { events: [], ts: Date.now() };
|
|
566
|
+
if (!liveEventBuffers[rid]) liveEventBuffers[rid] = { events: [], ts: Date.now() };
|
|
567
|
+
liveEventBuffers[rid].events.push(data);
|
|
568
|
+
liveEventBuffers[rid].ts = Date.now();
|
|
428
569
|
if (data.event === 'run:complete' || data.event === 'run:error') {
|
|
429
570
|
setTimeout(() => { delete liveEventBuffers[rid]; }, 30000);
|
|
430
571
|
}
|
|
431
572
|
}
|
|
432
573
|
|
|
574
|
+
// Purge stale live event buffers (runs that never completed, max 5 min)
|
|
575
|
+
const bufferPurgeInterval = setInterval(() => {
|
|
576
|
+
const maxAge = 5 * 60 * 1000;
|
|
577
|
+
for (const rid of Object.keys(liveEventBuffers)) {
|
|
578
|
+
if (Date.now() - liveEventBuffers[rid].ts > maxAge) {
|
|
579
|
+
delete liveEventBuffers[rid];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}, 30000);
|
|
583
|
+
|
|
433
584
|
const wss = createWebSocketServer(server, {
|
|
434
585
|
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
|
|
435
586
|
onConnect(socket) {
|
|
436
587
|
// Replay live state for new/reconnected clients
|
|
437
588
|
for (const rid of Object.keys(liveEventBuffers)) {
|
|
438
|
-
for (const evt of liveEventBuffers[rid]) {
|
|
589
|
+
for (const evt of liveEventBuffers[rid].events) {
|
|
439
590
|
wss.sendTo(socket, JSON.stringify(evt));
|
|
440
591
|
}
|
|
441
592
|
}
|
|
@@ -479,6 +630,7 @@ export async function startDashboard(config) {
|
|
|
479
630
|
runConfig = { ...config };
|
|
480
631
|
}
|
|
481
632
|
|
|
633
|
+
runConfig.triggeredBy = 'dashboard';
|
|
482
634
|
if (params.concurrency) runConfig.concurrency = params.concurrency;
|
|
483
635
|
if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
|
|
484
636
|
|
|
@@ -492,7 +644,7 @@ export async function startDashboard(config) {
|
|
|
492
644
|
if (params.suite) {
|
|
493
645
|
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
|
|
494
646
|
} else {
|
|
495
|
-
({ tests, hooks } = loadAllSuites(runConfig.testsDir));
|
|
647
|
+
({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
|
|
496
648
|
}
|
|
497
649
|
|
|
498
650
|
await waitForPool(runConfig.poolUrl);
|
|
@@ -509,8 +661,18 @@ export async function startDashboard(config) {
|
|
|
509
661
|
}
|
|
510
662
|
}
|
|
511
663
|
|
|
512
|
-
return new Promise((resolve) => {
|
|
664
|
+
return new Promise((resolve, reject) => {
|
|
513
665
|
const host = config.dashboardHost || '127.0.0.1';
|
|
666
|
+
|
|
667
|
+
server.on('error', (err) => {
|
|
668
|
+
if (err.code === 'EADDRINUSE') {
|
|
669
|
+
log('❌', `${C.red}Port ${port} is already in use. Try a different port with --port <number>.${C.reset}`);
|
|
670
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
671
|
+
} else {
|
|
672
|
+
reject(err);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
514
676
|
server.listen(port, host, () => {
|
|
515
677
|
log('🖥️', `${C.bold}Dashboard${C.reset} running at ${C.cyan}http://${host}:${port}${C.reset}`);
|
|
516
678
|
|
|
@@ -521,6 +683,7 @@ export async function startDashboard(config) {
|
|
|
521
683
|
close() {
|
|
522
684
|
clearInterval(pollInterval);
|
|
523
685
|
clearInterval(dbPollInterval);
|
|
686
|
+
clearInterval(bufferPurgeInterval);
|
|
524
687
|
wss.close();
|
|
525
688
|
server.close();
|
|
526
689
|
closeDb();
|
package/src/db.js
CHANGED
|
@@ -100,6 +100,27 @@ function migrate(db) {
|
|
|
100
100
|
db.exec('ALTER TABLE test_results ADD COLUMN screenshots TEXT');
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Add network_logs column if upgrading from older schema
|
|
104
|
+
try {
|
|
105
|
+
db.prepare('SELECT network_logs FROM test_results LIMIT 0').run();
|
|
106
|
+
} catch {
|
|
107
|
+
db.exec('ALTER TABLE test_results ADD COLUMN network_logs TEXT');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Add actions_json column if upgrading from older schema
|
|
111
|
+
try {
|
|
112
|
+
db.prepare('SELECT actions_json FROM test_results LIMIT 0').run();
|
|
113
|
+
} catch {
|
|
114
|
+
db.exec('ALTER TABLE test_results ADD COLUMN actions_json TEXT');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add triggered_by column if upgrading from older schema
|
|
118
|
+
try {
|
|
119
|
+
db.prepare('SELECT triggered_by FROM runs LIMIT 0').run();
|
|
120
|
+
} catch {
|
|
121
|
+
db.exec('ALTER TABLE runs ADD COLUMN triggered_by TEXT');
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
// Screenshot hashes table
|
|
104
125
|
db.exec(`
|
|
105
126
|
CREATE TABLE IF NOT EXISTS screenshot_hashes (
|
|
@@ -111,6 +132,113 @@ function migrate(db) {
|
|
|
111
132
|
);
|
|
112
133
|
CREATE INDEX IF NOT EXISTS idx_ss_path ON screenshot_hashes(file_path);
|
|
113
134
|
`);
|
|
135
|
+
|
|
136
|
+
// ── Learning system tables ──────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
db.exec(`
|
|
139
|
+
CREATE TABLE IF NOT EXISTS test_learnings (
|
|
140
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
141
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
142
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
143
|
+
test_name TEXT NOT NULL,
|
|
144
|
+
success INTEGER NOT NULL,
|
|
145
|
+
duration_ms INTEGER,
|
|
146
|
+
flaky INTEGER DEFAULT 0,
|
|
147
|
+
attempt INTEGER DEFAULT 1,
|
|
148
|
+
max_attempts INTEGER DEFAULT 1,
|
|
149
|
+
error_pattern TEXT,
|
|
150
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
151
|
+
);
|
|
152
|
+
CREATE INDEX IF NOT EXISTS idx_tl_project ON test_learnings(project_id);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_tl_test ON test_learnings(test_name);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_tl_created ON test_learnings(created_at);
|
|
155
|
+
|
|
156
|
+
CREATE TABLE IF NOT EXISTS selector_learnings (
|
|
157
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
158
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
159
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
160
|
+
selector TEXT NOT NULL,
|
|
161
|
+
action_type TEXT NOT NULL,
|
|
162
|
+
success INTEGER NOT NULL,
|
|
163
|
+
page_url TEXT,
|
|
164
|
+
test_name TEXT,
|
|
165
|
+
error TEXT,
|
|
166
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
167
|
+
);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_sl_project ON selector_learnings(project_id);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_sl_selector ON selector_learnings(selector);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS page_learnings (
|
|
172
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
173
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
174
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
175
|
+
url_path TEXT NOT NULL,
|
|
176
|
+
load_time_ms INTEGER,
|
|
177
|
+
console_errors INTEGER DEFAULT 0,
|
|
178
|
+
console_warns INTEGER DEFAULT 0,
|
|
179
|
+
network_errors INTEGER DEFAULT 0,
|
|
180
|
+
test_name TEXT,
|
|
181
|
+
success INTEGER NOT NULL,
|
|
182
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
183
|
+
);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_pl_project ON page_learnings(project_id);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_pl_url ON page_learnings(url_path);
|
|
186
|
+
|
|
187
|
+
CREATE TABLE IF NOT EXISTS api_learnings (
|
|
188
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
190
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
191
|
+
endpoint TEXT NOT NULL,
|
|
192
|
+
method TEXT NOT NULL,
|
|
193
|
+
status INTEGER,
|
|
194
|
+
duration_ms INTEGER,
|
|
195
|
+
is_error INTEGER DEFAULT 0,
|
|
196
|
+
test_name TEXT,
|
|
197
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
198
|
+
);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS error_patterns (
|
|
203
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
205
|
+
pattern TEXT NOT NULL,
|
|
206
|
+
category TEXT NOT NULL,
|
|
207
|
+
occurrence_count INTEGER DEFAULT 1,
|
|
208
|
+
first_seen TEXT DEFAULT (datetime('now')),
|
|
209
|
+
last_seen TEXT DEFAULT (datetime('now')),
|
|
210
|
+
example_error TEXT,
|
|
211
|
+
example_test TEXT,
|
|
212
|
+
UNIQUE(project_id, pattern)
|
|
213
|
+
);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_ep_project ON error_patterns(project_id);
|
|
215
|
+
CREATE INDEX IF NOT EXISTS idx_ep_cat ON error_patterns(category);
|
|
216
|
+
|
|
217
|
+
CREATE TABLE IF NOT EXISTS learning_summary (
|
|
218
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
219
|
+
project_id INTEGER NOT NULL UNIQUE REFERENCES projects(id),
|
|
220
|
+
total_runs INTEGER DEFAULT 0,
|
|
221
|
+
total_tests INTEGER DEFAULT 0,
|
|
222
|
+
overall_pass_rate REAL DEFAULT 0,
|
|
223
|
+
avg_duration_ms REAL DEFAULT 0,
|
|
224
|
+
flaky_tests TEXT,
|
|
225
|
+
slow_tests TEXT,
|
|
226
|
+
unstable_selectors TEXT,
|
|
227
|
+
failing_pages TEXT,
|
|
228
|
+
api_issues TEXT,
|
|
229
|
+
top_errors TEXT,
|
|
230
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
231
|
+
);
|
|
232
|
+
`);
|
|
233
|
+
|
|
234
|
+
// Migrations: add metadata columns to screenshot_hashes
|
|
235
|
+
const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
|
|
236
|
+
if (!ssColumns.includes('test_name')) {
|
|
237
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN test_name TEXT');
|
|
238
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN step_index INTEGER');
|
|
239
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN page_url TEXT');
|
|
240
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN screenshot_type TEXT');
|
|
241
|
+
}
|
|
114
242
|
}
|
|
115
243
|
|
|
116
244
|
/** Upsert a project row. Returns the project id. */
|
|
@@ -155,17 +283,19 @@ export function computeScreenshotHash(filePath) {
|
|
|
155
283
|
return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 8);
|
|
156
284
|
}
|
|
157
285
|
|
|
158
|
-
/** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. */
|
|
159
|
-
export function registerScreenshotHash(hash, filePath, projectId, runDbId) {
|
|
286
|
+
/** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. Optional metadata: testName, stepIndex, pageUrl, screenshotType. */
|
|
287
|
+
export function registerScreenshotHash(hash, filePath, projectId, runDbId, meta = {}) {
|
|
160
288
|
const d = getDb();
|
|
161
|
-
d.prepare(
|
|
289
|
+
d.prepare(
|
|
290
|
+
'INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id, test_name, step_index, page_url, screenshot_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
291
|
+
).run(hash, filePath, projectId || null, runDbId || null, meta.testName || null, meta.stepIndex ?? null, meta.pageUrl || null, meta.screenshotType || null);
|
|
162
292
|
}
|
|
163
293
|
|
|
164
|
-
/** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id } or null. */
|
|
294
|
+
/** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id, test_name, step_index, page_url, screenshot_type } or null. */
|
|
165
295
|
export function lookupScreenshotHash(rawHash) {
|
|
166
296
|
const d = getDb();
|
|
167
297
|
const hash = rawHash.replace(/^ss:/, '');
|
|
168
|
-
return d.prepare('SELECT hash, file_path, project_id FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
|
|
298
|
+
return d.prepare('SELECT hash, file_path, project_id, test_name, step_index, page_url, screenshot_type FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
|
|
169
299
|
}
|
|
170
300
|
|
|
171
301
|
/** Batch lookup: given an array of file paths, returns { [path]: hash } map. */
|
|
@@ -182,21 +312,21 @@ export function getScreenshotHashes(filePaths) {
|
|
|
182
312
|
}
|
|
183
313
|
|
|
184
314
|
/** Save a run + its test results in a single transaction. Returns the run's DB id. */
|
|
185
|
-
export function saveRun(projectId, report, runId, suiteName) {
|
|
315
|
+
export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
186
316
|
const d = getDb();
|
|
187
317
|
const { summary, results, generatedAt } = report;
|
|
188
318
|
|
|
189
319
|
const insertRun = d.prepare(`
|
|
190
|
-
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name)
|
|
191
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
320
|
+
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
|
|
321
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
192
322
|
`);
|
|
193
323
|
|
|
194
324
|
const insertTest = d.prepare(`
|
|
195
|
-
INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots)
|
|
196
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
325
|
+
INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots, network_logs, actions_json)
|
|
326
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
197
327
|
`);
|
|
198
328
|
|
|
199
|
-
const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)');
|
|
329
|
+
const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id, test_name, step_index, page_url, screenshot_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
200
330
|
|
|
201
331
|
const tx = d.transaction(() => {
|
|
202
332
|
const runInfo = insertRun.run(
|
|
@@ -209,6 +339,7 @@ export function saveRun(projectId, report, runId, suiteName) {
|
|
|
209
339
|
summary.duration,
|
|
210
340
|
generatedAt,
|
|
211
341
|
suiteName || null,
|
|
342
|
+
triggeredBy || null,
|
|
212
343
|
);
|
|
213
344
|
const runDbId = runInfo.lastInsertRowid;
|
|
214
345
|
|
|
@@ -222,6 +353,19 @@ export function saveRun(projectId, report, runId, suiteName) {
|
|
|
222
353
|
.filter(a => a.type === 'screenshot' && a.result?.screenshot)
|
|
223
354
|
.map(a => a.result.screenshot);
|
|
224
355
|
|
|
356
|
+
// Condensed actions for narrative display
|
|
357
|
+
const actionsCondensed = (r.actions || []).map(a => ({
|
|
358
|
+
type: a.type,
|
|
359
|
+
selector: a.selector || undefined,
|
|
360
|
+
value: a.value || undefined,
|
|
361
|
+
text: a.text || undefined,
|
|
362
|
+
success: a.success,
|
|
363
|
+
duration: a.duration,
|
|
364
|
+
narrative: a.narrative || undefined,
|
|
365
|
+
error: a.error || undefined,
|
|
366
|
+
actionRetries: a.actionRetries || undefined,
|
|
367
|
+
}));
|
|
368
|
+
|
|
225
369
|
insertTest.run(
|
|
226
370
|
runDbId,
|
|
227
371
|
r.name,
|
|
@@ -236,14 +380,22 @@ export function saveRun(projectId, report, runId, suiteName) {
|
|
|
236
380
|
r.consoleLogs ? JSON.stringify(r.consoleLogs) : null,
|
|
237
381
|
r.networkErrors ? JSON.stringify(r.networkErrors) : null,
|
|
238
382
|
screenshots.length ? JSON.stringify(screenshots) : null,
|
|
383
|
+
r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
|
|
384
|
+
actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
|
|
239
385
|
);
|
|
240
386
|
|
|
241
|
-
// Register screenshot hashes
|
|
242
|
-
|
|
243
|
-
|
|
387
|
+
// Register screenshot hashes with metadata
|
|
388
|
+
const ssActions = (r.actions || []).filter(a => a.type === 'screenshot' && a.result?.screenshot);
|
|
389
|
+
for (let si = 0; si < ssActions.length; si++) {
|
|
390
|
+
const a = ssActions[si];
|
|
391
|
+
const actionIdx = r.actions.indexOf(a);
|
|
392
|
+
insertHash.run(computeScreenshotHash(a.result.screenshot), a.result.screenshot, projectId, runDbId, r.name, actionIdx, null, 'action');
|
|
244
393
|
}
|
|
245
394
|
if (r.errorScreenshot) {
|
|
246
|
-
insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId);
|
|
395
|
+
insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId, r.name, null, null, 'error');
|
|
396
|
+
}
|
|
397
|
+
if (r.verificationScreenshot) {
|
|
398
|
+
insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
|
|
247
399
|
}
|
|
248
400
|
}
|
|
249
401
|
|
|
@@ -273,7 +425,7 @@ export function listProjects() {
|
|
|
273
425
|
export function getProjectRuns(projectId, limit = 50, offset = 0) {
|
|
274
426
|
const d = getDb();
|
|
275
427
|
return d.prepare(`
|
|
276
|
-
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name
|
|
428
|
+
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
|
|
277
429
|
FROM runs
|
|
278
430
|
WHERE project_id = ?
|
|
279
431
|
ORDER BY generated_at DESC
|
|
@@ -310,6 +462,7 @@ export function getRunDetail(runDbId) {
|
|
|
310
462
|
},
|
|
311
463
|
generatedAt: run.generated_at,
|
|
312
464
|
suiteName: run.suite_name,
|
|
465
|
+
triggeredBy: run.triggered_by || null,
|
|
313
466
|
results: tests.map(t => {
|
|
314
467
|
const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
|
|
315
468
|
const testPaths = [...screenshots];
|
|
@@ -331,6 +484,8 @@ export function getRunDetail(runDbId) {
|
|
|
331
484
|
screenshots,
|
|
332
485
|
consoleLogs: t.console_logs ? JSON.parse(t.console_logs) : [],
|
|
333
486
|
networkErrors: t.network_errors ? JSON.parse(t.network_errors) : [],
|
|
487
|
+
networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
|
|
488
|
+
actions: t.actions_json ? JSON.parse(t.actions_json) : [],
|
|
334
489
|
screenshotHashes,
|
|
335
490
|
};
|
|
336
491
|
}),
|
|
@@ -342,7 +497,7 @@ export function getAllRuns(limit = 50, offset = 0) {
|
|
|
342
497
|
const d = getDb();
|
|
343
498
|
return d.prepare(`
|
|
344
499
|
SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
|
|
345
|
-
r.generated_at, r.suite_name, p.name AS project_name, p.id AS project_id
|
|
500
|
+
r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
|
|
346
501
|
FROM runs r
|
|
347
502
|
JOIN projects p ON p.id = r.project_id
|
|
348
503
|
ORDER BY r.generated_at DESC
|
|
@@ -357,6 +512,66 @@ export function getRunCount() {
|
|
|
357
512
|
return row.cnt;
|
|
358
513
|
}
|
|
359
514
|
|
|
515
|
+
/** Query network logs for a run with optional filters.
|
|
516
|
+
* Filters: testName, method, statusMin, statusMax, urlPattern, errorsOnly, includeHeaders, includeBodies.
|
|
517
|
+
* By default returns only: url, method, status, statusText, duration.
|
|
518
|
+
*/
|
|
519
|
+
export function getNetworkLogs(runDbId, filters = {}) {
|
|
520
|
+
const d = getDb();
|
|
521
|
+
|
|
522
|
+
let query = 'SELECT name, network_logs FROM test_results WHERE run_id = ?';
|
|
523
|
+
const params = [runDbId];
|
|
524
|
+
|
|
525
|
+
if (filters.testName) {
|
|
526
|
+
query += ' AND name = ?';
|
|
527
|
+
params.push(filters.testName);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const rows = d.prepare(query).all(...params);
|
|
531
|
+
const results = [];
|
|
532
|
+
|
|
533
|
+
for (const row of rows) {
|
|
534
|
+
if (!row.network_logs) continue;
|
|
535
|
+
let logs = JSON.parse(row.network_logs);
|
|
536
|
+
|
|
537
|
+
if (filters.method) {
|
|
538
|
+
logs = logs.filter(l => l.method === filters.method.toUpperCase());
|
|
539
|
+
}
|
|
540
|
+
if (filters.statusMin !== undefined) {
|
|
541
|
+
logs = logs.filter(l => l.status >= filters.statusMin);
|
|
542
|
+
}
|
|
543
|
+
if (filters.statusMax !== undefined) {
|
|
544
|
+
logs = logs.filter(l => l.status <= filters.statusMax);
|
|
545
|
+
}
|
|
546
|
+
if (filters.urlPattern) {
|
|
547
|
+
const re = new RegExp(filters.urlPattern, 'i');
|
|
548
|
+
logs = logs.filter(l => re.test(l.url));
|
|
549
|
+
}
|
|
550
|
+
if (filters.errorsOnly) {
|
|
551
|
+
logs = logs.filter(l => l.status >= 400);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const mapped = logs.map(l => {
|
|
555
|
+
const entry = { url: l.url, method: l.method, status: l.status, statusText: l.statusText, duration: l.duration };
|
|
556
|
+
if (filters.includeHeaders || filters.includeBodies) {
|
|
557
|
+
entry.requestHeaders = l.requestHeaders;
|
|
558
|
+
entry.responseHeaders = l.responseHeaders;
|
|
559
|
+
}
|
|
560
|
+
if (filters.includeBodies) {
|
|
561
|
+
entry.requestBody = l.requestBody;
|
|
562
|
+
entry.responseBody = l.responseBody;
|
|
563
|
+
}
|
|
564
|
+
return entry;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
if (mapped.length > 0) {
|
|
568
|
+
results.push({ testName: row.name, logs: mapped });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return results;
|
|
573
|
+
}
|
|
574
|
+
|
|
360
575
|
/** Close the database connection. */
|
|
361
576
|
export function closeDb() {
|
|
362
577
|
if (db) {
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,12 @@ export { startDashboard, stopDashboard } from './dashboard.js';
|
|
|
16
16
|
export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
|
|
17
17
|
export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
|
|
18
18
|
export { verifyIssue } from './verify.js';
|
|
19
|
+
export { resolveTestData, loadModuleRegistry, listModules } from './module-resolver.js';
|
|
20
|
+
export { learnFromRun, categorizeError } from './learner.js';
|
|
21
|
+
export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights } from './learner-sqlite.js';
|
|
22
|
+
export { generateLearningsMarkdown } from './learner-markdown.js';
|
|
23
|
+
export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
|
|
24
|
+
export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
|
|
19
25
|
|
|
20
26
|
import { loadConfig } from './config.js';
|
|
21
27
|
import { waitForPool } from './pool.js';
|
|
@@ -36,7 +42,7 @@ export async function createRunner(userConfig = {}) {
|
|
|
36
42
|
/** Runs all test suites from the tests directory */
|
|
37
43
|
async runAll() {
|
|
38
44
|
await waitForPool(config.poolUrl);
|
|
39
|
-
const { tests, hooks } = loadAllSuites(config.testsDir);
|
|
45
|
+
const { tests, hooks } = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
|
|
40
46
|
const results = await runTestsParallel(tests, config, hooks);
|
|
41
47
|
const report = generateReport(results);
|
|
42
48
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -47,7 +53,7 @@ export async function createRunner(userConfig = {}) {
|
|
|
47
53
|
/** Runs a single suite by name */
|
|
48
54
|
async runSuite(name) {
|
|
49
55
|
await waitForPool(config.poolUrl);
|
|
50
|
-
const { tests, hooks } = loadTestSuite(name, config.testsDir);
|
|
56
|
+
const { tests, hooks } = loadTestSuite(name, config.testsDir, config.modulesDir);
|
|
51
57
|
const results = await runTestsParallel(tests, config, hooks);
|
|
52
58
|
const report = generateReport(results);
|
|
53
59
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -68,7 +74,7 @@ export async function createRunner(userConfig = {}) {
|
|
|
68
74
|
/** Runs tests from a JSON file path */
|
|
69
75
|
async runFile(filePath) {
|
|
70
76
|
await waitForPool(config.poolUrl);
|
|
71
|
-
const { tests, hooks } = loadTestFile(filePath);
|
|
77
|
+
const { tests, hooks } = loadTestFile(filePath, config.modulesDir);
|
|
72
78
|
const results = await runTestsParallel(tests, config, hooks);
|
|
73
79
|
const report = generateReport(results);
|
|
74
80
|
saveReport(report, config.screenshotsDir, config);
|