@matware/e2e-runner 1.3.0 → 1.3.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/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +61 -526
- package/bin/cli.js +5 -4
- package/commands/capture.md +45 -0
- package/package.json +1 -1
- package/src/actions.js +151 -0
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +125 -7
- package/src/dashboard.js +75 -8
- package/src/db.js +63 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +251 -32
- package/src/narrate.js +28 -0
- package/src/pool-manager.js +22 -16
- package/src/pool.js +301 -31
- package/src/reporter.js +4 -1
- package/src/runner.js +335 -55
- package/src/visual-diff.js +446 -0
- package/templates/dashboard/js/api.js +2 -0
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +40 -2
- package/templates/dashboard/js/view-runs.js +161 -57
- package/templates/dashboard/js/websocket.js +6 -0
- package/templates/dashboard/styles/components.css +7 -0
- package/templates/dashboard/styles/view-live.css +24 -1
- package/templates/dashboard/styles/view-runs.css +36 -0
- package/templates/dashboard/template.html +24 -9
- package/templates/dashboard.html +322 -310
package/src/dashboard.js
CHANGED
|
@@ -20,7 +20,8 @@ import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory,
|
|
|
20
20
|
import { listProjects as dbListProjects, listProjectsWithSparklines as dbListProjectsWithSparklines, 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, listVariables as dbListVariables, setVariable as dbSetVariable, deleteVariable as dbDeleteVariable, 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, getRunInsights, getHealthSnapshot } from './learner-sqlite.js';
|
|
23
|
+
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getHealthSnapshot, getActionHealthScores } from './learner-sqlite.js';
|
|
24
|
+
import { compareImages } from './visual-diff.js';
|
|
24
25
|
import { handleSyncRoutes } from './sync/hub-routes.js';
|
|
25
26
|
import { migrateSyncSchema } from './sync/schema.js';
|
|
26
27
|
|
|
@@ -120,7 +121,7 @@ export async function startDashboard(config) {
|
|
|
120
121
|
// API: pool status + dashboard state
|
|
121
122
|
if (pathname === '/api/status') {
|
|
122
123
|
const poolUrls = getPoolUrls(config);
|
|
123
|
-
const aggregated = await getAggregatedPoolStatus(poolUrls);
|
|
124
|
+
const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
124
125
|
jsonResponse(res, {
|
|
125
126
|
pool: aggregated,
|
|
126
127
|
poolUrls,
|
|
@@ -305,6 +306,38 @@ export async function startDashboard(config) {
|
|
|
305
306
|
return;
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
// API: DB — project config warnings (Docker hostname detection)
|
|
310
|
+
const configWarningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/config-warnings$/);
|
|
311
|
+
if (configWarningsMatch) {
|
|
312
|
+
try {
|
|
313
|
+
const projectId = parseInt(configWarningsMatch[1], 10);
|
|
314
|
+
const projectCwd = dbGetProjectCwd(projectId);
|
|
315
|
+
if (!projectCwd) { jsonResponse(res, { warnings: [] }); return; }
|
|
316
|
+
const cfg = await loadConfig({}, projectCwd);
|
|
317
|
+
const warnings = [];
|
|
318
|
+
const checkDockerHostname = (url, label) => {
|
|
319
|
+
try {
|
|
320
|
+
const parsed = new URL(url);
|
|
321
|
+
if (!parsed.hostname.includes('.') && parsed.hostname !== 'localhost' && parsed.hostname !== '127') {
|
|
322
|
+
warnings.push({
|
|
323
|
+
type: 'docker-hostname',
|
|
324
|
+
field: label,
|
|
325
|
+
hostname: parsed.hostname,
|
|
326
|
+
url,
|
|
327
|
+
message: `"${parsed.hostname}" looks like a Docker-internal hostname. The runner will auto-fallback to localhost for auth, but baseUrl requests go through Chrome in Docker (which can resolve it). If tests fail with ENOTFOUND, ensure Chrome pool is on the same Docker network.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
};
|
|
332
|
+
if (cfg.baseUrl) checkDockerHostname(cfg.baseUrl, 'baseUrl');
|
|
333
|
+
if (cfg.authLoginEndpoint) checkDockerHostname(cfg.authLoginEndpoint, 'authLoginEndpoint');
|
|
334
|
+
jsonResponse(res, { warnings });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
jsonResponse(res, { warnings: [], error: error.message });
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
// API: DB — project screenshots list
|
|
309
342
|
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
310
343
|
if (projectScreenshotsMatch) {
|
|
@@ -498,6 +531,9 @@ export async function startDashboard(config) {
|
|
|
498
531
|
case 'trends':
|
|
499
532
|
data = getTestTrends(projectId, days);
|
|
500
533
|
break;
|
|
534
|
+
case 'actions':
|
|
535
|
+
data = getActionHealthScores(projectId, days);
|
|
536
|
+
break;
|
|
501
537
|
default:
|
|
502
538
|
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
503
539
|
return;
|
|
@@ -509,6 +545,30 @@ export async function startDashboard(config) {
|
|
|
509
545
|
return;
|
|
510
546
|
}
|
|
511
547
|
|
|
548
|
+
// API: visual diff — compare two screenshots on demand
|
|
549
|
+
if (pathname === '/api/visual-diff') {
|
|
550
|
+
try {
|
|
551
|
+
const baseline = url.searchParams.get('baseline');
|
|
552
|
+
const current = url.searchParams.get('current');
|
|
553
|
+
const thresholdParam = url.searchParams.get('threshold');
|
|
554
|
+
if (!baseline || !current) {
|
|
555
|
+
jsonResponse(res, { error: 'Missing baseline or current parameter' }, 400); return;
|
|
556
|
+
}
|
|
557
|
+
if (!fs.existsSync(baseline)) { jsonResponse(res, { error: `Baseline not found: ${baseline}` }, 404); return; }
|
|
558
|
+
if (!fs.existsSync(current)) { jsonResponse(res, { error: `Current not found: ${current}` }, 404); return; }
|
|
559
|
+
|
|
560
|
+
const diffPath = path.join(config.screenshotsDir, `api-diff-${Date.now()}.png`);
|
|
561
|
+
const result = compareImages(baseline, current, {
|
|
562
|
+
threshold: thresholdParam ? parseFloat(thresholdParam) : 0.1,
|
|
563
|
+
diffOutputPath: diffPath,
|
|
564
|
+
});
|
|
565
|
+
jsonResponse(res, { ...result, diffImagePath: diffPath });
|
|
566
|
+
} catch (error) {
|
|
567
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
512
572
|
// API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
|
|
513
573
|
const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
|
|
514
574
|
if (ssHashMatch) {
|
|
@@ -663,7 +723,9 @@ export async function startDashboard(config) {
|
|
|
663
723
|
if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
|
|
664
724
|
try {
|
|
665
725
|
const data = JSON.parse(body);
|
|
666
|
-
|
|
726
|
+
if (data.event !== 'test:frame') {
|
|
727
|
+
bufferLiveEvent(data);
|
|
728
|
+
}
|
|
667
729
|
wss.broadcast(JSON.stringify(data));
|
|
668
730
|
} catch { /* */ }
|
|
669
731
|
jsonResponse(res, { ok: true });
|
|
@@ -711,8 +773,9 @@ export async function startDashboard(config) {
|
|
|
711
773
|
}
|
|
712
774
|
}, 30000);
|
|
713
775
|
|
|
776
|
+
const devOrigins = process.env.NODE_ENV === 'production' ? [] : ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
714
777
|
const wss = createWebSocketServer(server, {
|
|
715
|
-
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}
|
|
778
|
+
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`, ...devOrigins],
|
|
716
779
|
onConnect(socket) {
|
|
717
780
|
// Replay live state for new/reconnected clients
|
|
718
781
|
for (const rid of Object.keys(liveEventBuffers)) {
|
|
@@ -727,7 +790,7 @@ export async function startDashboard(config) {
|
|
|
727
790
|
const poolUrls = getPoolUrls(config);
|
|
728
791
|
const pollInterval = setInterval(async () => {
|
|
729
792
|
try {
|
|
730
|
-
const aggregated = await getAggregatedPoolStatus(poolUrls);
|
|
793
|
+
const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
731
794
|
wss.broadcast(JSON.stringify({ event: 'pool:status', data: aggregated }));
|
|
732
795
|
} catch { /* */ }
|
|
733
796
|
}, 5000);
|
|
@@ -765,21 +828,25 @@ export async function startDashboard(config) {
|
|
|
765
828
|
runConfig.triggeredBy = 'dashboard';
|
|
766
829
|
if (params.concurrency) runConfig.concurrency = params.concurrency;
|
|
767
830
|
if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
|
|
831
|
+
if (params.screencast !== undefined) runConfig.screencast = params.screencast;
|
|
768
832
|
|
|
769
833
|
// Wire up onProgress to broadcast WS events
|
|
770
834
|
runConfig.onProgress = (data) => {
|
|
771
|
-
|
|
835
|
+
// Don't buffer screencast frames — they're ephemeral and high volume
|
|
836
|
+
if (data.event !== 'test:frame') {
|
|
837
|
+
bufferLiveEvent(data);
|
|
838
|
+
}
|
|
772
839
|
wss.broadcast(JSON.stringify(data));
|
|
773
840
|
};
|
|
774
841
|
|
|
775
842
|
let tests, hooks;
|
|
776
843
|
if (params.suite) {
|
|
777
|
-
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
|
|
844
|
+
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir, runConfig.modulesDir));
|
|
778
845
|
} else {
|
|
779
846
|
({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
|
|
780
847
|
}
|
|
781
848
|
|
|
782
|
-
await waitForAnyPool(getPoolUrls(runConfig));
|
|
849
|
+
await waitForAnyPool(getPoolUrls(runConfig), 30000, { poolDriver: runConfig.poolDriver, maxSessions: runConfig.maxSessions });
|
|
783
850
|
const results = await runTestsParallel(tests, runConfig, hooks || {});
|
|
784
851
|
const report = generateReport(results);
|
|
785
852
|
const suiteName = params.suite || null;
|
package/src/db.js
CHANGED
|
@@ -199,6 +199,24 @@ function migrate(db) {
|
|
|
199
199
|
CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
|
|
200
200
|
CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
|
|
201
201
|
|
|
202
|
+
CREATE TABLE IF NOT EXISTS action_health (
|
|
203
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
205
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
206
|
+
test_name TEXT NOT NULL,
|
|
207
|
+
action_index INTEGER NOT NULL,
|
|
208
|
+
action_type TEXT NOT NULL,
|
|
209
|
+
selector TEXT,
|
|
210
|
+
success INTEGER NOT NULL,
|
|
211
|
+
duration_ms INTEGER,
|
|
212
|
+
console_errors_after INTEGER DEFAULT 0,
|
|
213
|
+
network_errors_after INTEGER DEFAULT 0,
|
|
214
|
+
page_url TEXT,
|
|
215
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
216
|
+
);
|
|
217
|
+
CREATE INDEX IF NOT EXISTS idx_ah_project ON action_health(project_id);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_ah_selector ON action_health(selector);
|
|
219
|
+
|
|
202
220
|
CREATE TABLE IF NOT EXISTS error_patterns (
|
|
203
221
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
222
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
@@ -255,6 +273,28 @@ function migrate(db) {
|
|
|
255
273
|
db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
|
|
256
274
|
}
|
|
257
275
|
|
|
276
|
+
// Add pool_driver column to runs for driver visibility
|
|
277
|
+
try {
|
|
278
|
+
db.prepare('SELECT pool_driver FROM runs LIMIT 0').run();
|
|
279
|
+
} catch {
|
|
280
|
+
db.exec('ALTER TABLE runs ADD COLUMN pool_driver TEXT');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Add visual diff columns to test_results
|
|
284
|
+
const trCols = db.pragma('table_info(test_results)').map(c => c.name);
|
|
285
|
+
if (!trCols.includes('baseline_screenshot')) {
|
|
286
|
+
db.exec('ALTER TABLE test_results ADD COLUMN baseline_screenshot TEXT');
|
|
287
|
+
}
|
|
288
|
+
if (!trCols.includes('verification_screenshot')) {
|
|
289
|
+
db.exec('ALTER TABLE test_results ADD COLUMN verification_screenshot TEXT');
|
|
290
|
+
}
|
|
291
|
+
if (!trCols.includes('diff_screenshot')) {
|
|
292
|
+
db.exec('ALTER TABLE test_results ADD COLUMN diff_screenshot TEXT');
|
|
293
|
+
}
|
|
294
|
+
if (!trCols.includes('visual_diff_json')) {
|
|
295
|
+
db.exec('ALTER TABLE test_results ADD COLUMN visual_diff_json TEXT');
|
|
296
|
+
}
|
|
297
|
+
|
|
258
298
|
// Migrations: add metadata columns to screenshot_hashes
|
|
259
299
|
const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
|
|
260
300
|
if (!ssColumns.includes('test_name')) {
|
|
@@ -336,18 +376,18 @@ export function getScreenshotHashes(filePaths) {
|
|
|
336
376
|
}
|
|
337
377
|
|
|
338
378
|
/** Save a run + its test results in a single transaction. Returns the run's DB id. */
|
|
339
|
-
export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
379
|
+
export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDriver) {
|
|
340
380
|
const d = getDb();
|
|
341
381
|
const { summary, results, generatedAt } = report;
|
|
342
382
|
|
|
343
383
|
const insertRun = d.prepare(`
|
|
344
|
-
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
|
|
345
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
384
|
+
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver)
|
|
385
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
346
386
|
`);
|
|
347
387
|
|
|
348
388
|
const insertTest = d.prepare(`
|
|
349
|
-
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, pool_url)
|
|
350
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
389
|
+
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, pool_url, baseline_screenshot, verification_screenshot, diff_screenshot, visual_diff_json)
|
|
390
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
351
391
|
`);
|
|
352
392
|
|
|
353
393
|
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 (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
@@ -364,6 +404,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
364
404
|
generatedAt,
|
|
365
405
|
suiteName || null,
|
|
366
406
|
triggeredBy || null,
|
|
407
|
+
poolDriver || null,
|
|
367
408
|
);
|
|
368
409
|
const runDbId = runInfo.lastInsertRowid;
|
|
369
410
|
|
|
@@ -407,6 +448,10 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
407
448
|
r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
|
|
408
449
|
actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
|
|
409
450
|
r.poolUrl || null,
|
|
451
|
+
r.baselineScreenshot || null,
|
|
452
|
+
r.verificationScreenshot || null,
|
|
453
|
+
r.diffScreenshot || null,
|
|
454
|
+
r.visualDiff ? JSON.stringify(r.visualDiff) : null,
|
|
410
455
|
);
|
|
411
456
|
|
|
412
457
|
// Register screenshot hashes with metadata
|
|
@@ -425,6 +470,9 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
425
470
|
if (r.baselineScreenshot) {
|
|
426
471
|
insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
|
|
427
472
|
}
|
|
473
|
+
if (r.diffScreenshot) {
|
|
474
|
+
insertHash.run(computeScreenshotHash(r.diffScreenshot), r.diffScreenshot, projectId, runDbId, r.name, null, null, 'diff');
|
|
475
|
+
}
|
|
428
476
|
}
|
|
429
477
|
|
|
430
478
|
return runDbId;
|
|
@@ -480,7 +528,7 @@ export function listProjects() {
|
|
|
480
528
|
export function getProjectRuns(projectId, limit = 50, offset = 0) {
|
|
481
529
|
const d = getDb();
|
|
482
530
|
return d.prepare(`
|
|
483
|
-
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
|
|
531
|
+
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver
|
|
484
532
|
FROM runs
|
|
485
533
|
WHERE project_id = ?
|
|
486
534
|
ORDER BY generated_at DESC
|
|
@@ -503,6 +551,9 @@ export function getRunDetail(runDbId) {
|
|
|
503
551
|
const ss = t.screenshots ? JSON.parse(t.screenshots) : [];
|
|
504
552
|
allPaths.push(...ss);
|
|
505
553
|
if (t.error_screenshot) allPaths.push(t.error_screenshot);
|
|
554
|
+
if (t.baseline_screenshot) allPaths.push(t.baseline_screenshot);
|
|
555
|
+
if (t.verification_screenshot) allPaths.push(t.verification_screenshot);
|
|
556
|
+
if (t.diff_screenshot) allPaths.push(t.diff_screenshot);
|
|
506
557
|
}
|
|
507
558
|
const hashMap = getScreenshotHashes(allPaths);
|
|
508
559
|
|
|
@@ -518,6 +569,7 @@ export function getRunDetail(runDbId) {
|
|
|
518
569
|
generatedAt: run.generated_at,
|
|
519
570
|
suiteName: run.suite_name,
|
|
520
571
|
triggeredBy: run.triggered_by || null,
|
|
572
|
+
poolDriver: run.pool_driver || null,
|
|
521
573
|
results: tests.map(t => {
|
|
522
574
|
const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
|
|
523
575
|
const testPaths = [...screenshots];
|
|
@@ -543,6 +595,10 @@ export function getRunDetail(runDbId) {
|
|
|
543
595
|
actions: t.actions_json ? JSON.parse(t.actions_json) : [],
|
|
544
596
|
screenshotHashes,
|
|
545
597
|
poolUrl: t.pool_url || null,
|
|
598
|
+
baselineScreenshot: t.baseline_screenshot || null,
|
|
599
|
+
verificationScreenshot: t.verification_screenshot || null,
|
|
600
|
+
diffScreenshot: t.diff_screenshot || null,
|
|
601
|
+
visualDiff: t.visual_diff_json ? JSON.parse(t.visual_diff_json) : null,
|
|
546
602
|
};
|
|
547
603
|
}),
|
|
548
604
|
};
|
|
@@ -553,7 +609,7 @@ export function getAllRuns(limit = 50, offset = 0) {
|
|
|
553
609
|
const d = getDb();
|
|
554
610
|
return d.prepare(`
|
|
555
611
|
SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
|
|
556
|
-
r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
|
|
612
|
+
r.generated_at, r.suite_name, r.triggered_by, r.pool_driver, p.name AS project_name, p.id AS project_id
|
|
557
613
|
FROM runs r
|
|
558
614
|
JOIN projects p ON p.id = r.project_id
|
|
559
615
|
ORDER BY r.generated_at DESC
|
package/src/index.js
CHANGED
|
@@ -8,21 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export { loadConfig } from './config.js';
|
|
11
|
-
export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
|
|
11
|
+
export { waitForPool, connectToPool, disconnectFromPool, startPool, stopPool, restartPool, getPoolStatus, clearDriverCache, getCachedDriver, trackCdpSession, releaseCdpSession, releaseSteelSession } from './pool.js';
|
|
12
12
|
export { getPoolUrls, getAllPoolStatuses, getAggregatedPoolStatus, waitForAnyPool, selectPool, selectAndConnect } from './pool-manager.js';
|
|
13
13
|
export { executeAction } from './actions.js';
|
|
14
|
-
export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
|
|
14
|
+
export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites, fetchAuthToken } from './runner.js';
|
|
15
15
|
export { generateReport, generateJUnitXML, saveReport, printReport, saveHistory, loadHistory, loadHistoryRun } from './reporter.js';
|
|
16
16
|
export { startDashboard, stopDashboard } from './dashboard.js';
|
|
17
17
|
export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
|
|
18
18
|
export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
|
|
19
19
|
export { verifyIssue } from './verify.js';
|
|
20
20
|
export { resolveTestData, loadModuleRegistry, listModules } from './module-resolver.js';
|
|
21
|
-
export { learnFromRun, categorizeError } from './learner.js';
|
|
22
|
-
export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestCreationContext, generateImprovements } from './learner-sqlite.js';
|
|
21
|
+
export { learnFromRun, categorizeError, isInfraError, INFRA_CATEGORIES } from './learner.js';
|
|
22
|
+
export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestCreationContext, generateImprovements, getActionHealthScores } from './learner-sqlite.js';
|
|
23
23
|
export { generateLearningsMarkdown } from './learner-markdown.js';
|
|
24
24
|
export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
|
|
25
25
|
export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
|
|
26
|
+
export { forkAppInstance, destroyFork, destroyAllForks, getAppPoolStatus, isAppPoolEnabled } from './app-pool.js';
|
|
27
|
+
export { compareImages, assertVisualMatch } from './visual-diff.js';
|
|
26
28
|
|
|
27
29
|
import { loadConfig } from './config.js';
|
|
28
30
|
import { waitForAnyPool, getPoolUrls } from './pool-manager.js';
|
package/src/learner-sqlite.js
CHANGED
|
@@ -293,6 +293,117 @@ export function getRunInsights(projectId, report) {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// ── At-Least-One Guarantee: generate positive insights if none exist ──
|
|
297
|
+
if (insights.length === 0 && report.results.length > 0) {
|
|
298
|
+
const allPassed = report.results.every(r => r.success);
|
|
299
|
+
|
|
300
|
+
// Green streak detection
|
|
301
|
+
if (allPassed) {
|
|
302
|
+
const recentRuns = d.prepare(`
|
|
303
|
+
SELECT run_id, MIN(success) AS all_passed
|
|
304
|
+
FROM test_learnings
|
|
305
|
+
WHERE project_id = ?
|
|
306
|
+
GROUP BY run_id
|
|
307
|
+
ORDER BY created_at DESC
|
|
308
|
+
LIMIT 10
|
|
309
|
+
`).all(projectId);
|
|
310
|
+
const streak = recentRuns.findIndex(r => r.all_passed === 0);
|
|
311
|
+
const streakCount = streak === -1 ? recentRuns.length : streak;
|
|
312
|
+
if (streakCount >= 3) {
|
|
313
|
+
insights.push({
|
|
314
|
+
type: 'green-streak',
|
|
315
|
+
streak: streakCount,
|
|
316
|
+
message: `${streakCount}-run green streak — suite is stable.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// New tests (no historical data)
|
|
322
|
+
const newTests = report.results.filter(r => {
|
|
323
|
+
const h = d.prepare('SELECT COUNT(*) AS c FROM test_learnings WHERE project_id = ? AND test_name = ?').get(projectId, r.name);
|
|
324
|
+
return !h || h.c <= 1; // <= 1 because current run may already be written
|
|
325
|
+
});
|
|
326
|
+
if (newTests.length > 0) {
|
|
327
|
+
insights.push({
|
|
328
|
+
type: 'new-tests',
|
|
329
|
+
tests: newTests.map(t => t.name),
|
|
330
|
+
message: `${newTests.length} new test(s): ${newTests.map(t => t.name).slice(0, 3).join(', ')}${newTests.length > 3 ? '...' : ''}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Pass rate improvement vs 7-day average
|
|
335
|
+
const avg7d = d.prepare(`
|
|
336
|
+
SELECT ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate
|
|
337
|
+
FROM test_learnings
|
|
338
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-7 days')
|
|
339
|
+
`).get(projectId);
|
|
340
|
+
const thisRunPassRate = Math.round((report.results.filter(r => r.success).length / report.results.length) * 1000) / 10;
|
|
341
|
+
if (avg7d?.pass_rate && thisRunPassRate > avg7d.pass_rate + 5) {
|
|
342
|
+
insights.push({
|
|
343
|
+
type: 'improved-pass-rate',
|
|
344
|
+
message: `Pass rate improved: ${thisRunPassRate}% this run vs ${avg7d.pass_rate}% 7-day average.`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Performance comparison
|
|
349
|
+
const avgDuration = d.prepare(`
|
|
350
|
+
SELECT ROUND(AVG(duration_ms)) AS avg_ms
|
|
351
|
+
FROM test_learnings
|
|
352
|
+
WHERE project_id = ? AND duration_ms IS NOT NULL AND created_at >= datetime('now', '-30 days')
|
|
353
|
+
`).get(projectId);
|
|
354
|
+
if (avgDuration?.avg_ms && report.results.length > 0) {
|
|
355
|
+
const thisAvg = report.results.reduce((s, r) => {
|
|
356
|
+
const ms = (r.endTime && r.startTime) ? new Date(r.endTime) - new Date(r.startTime) : 0;
|
|
357
|
+
return s + ms;
|
|
358
|
+
}, 0) / report.results.length;
|
|
359
|
+
const delta = Math.round(((thisAvg - avgDuration.avg_ms) / avgDuration.avg_ms) * 100);
|
|
360
|
+
if (Math.abs(delta) > 15) {
|
|
361
|
+
insights.push({
|
|
362
|
+
type: 'performance',
|
|
363
|
+
message: delta < 0
|
|
364
|
+
? `This run was ${Math.abs(delta)}% faster than the 30-day average.`
|
|
365
|
+
: `This run was ${delta}% slower than the 30-day average — check for new slow pages.`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Stable selectors confirmed
|
|
371
|
+
if (allPassed) {
|
|
372
|
+
const usedSelectors = new Set();
|
|
373
|
+
for (const r of report.results) {
|
|
374
|
+
if (!r.actions) continue;
|
|
375
|
+
for (const a of r.actions) {
|
|
376
|
+
if (a.selector) usedSelectors.add(a.selector);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (usedSelectors.size > 0) {
|
|
380
|
+
const stableCount = d.prepare(`
|
|
381
|
+
SELECT COUNT(DISTINCT selector) AS c
|
|
382
|
+
FROM selector_learnings
|
|
383
|
+
WHERE project_id = ? AND selector IN (${[...usedSelectors].map(() => '?').join(',')})
|
|
384
|
+
GROUP BY selector
|
|
385
|
+
HAVING SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0 AND COUNT(*) > 3
|
|
386
|
+
`).all(projectId, ...usedSelectors).length;
|
|
387
|
+
if (stableCount > 0) {
|
|
388
|
+
insights.push({
|
|
389
|
+
type: 'stable-selectors',
|
|
390
|
+
count: stableCount,
|
|
391
|
+
message: `${stableCount} selector(s) confirmed stable across multiple runs.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Fallback: if still no insights, report basic run stats
|
|
398
|
+
if (insights.length === 0) {
|
|
399
|
+
const passed = report.results.filter(r => r.success).length;
|
|
400
|
+
insights.push({
|
|
401
|
+
type: 'run-summary',
|
|
402
|
+
message: `${passed}/${report.results.length} tests passed (${thisRunPassRate}%).`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
296
407
|
return insights;
|
|
297
408
|
}
|
|
298
409
|
|
|
@@ -397,6 +508,49 @@ export function getSelectorHistory(projectId, selector, days = 30) {
|
|
|
397
508
|
* Aggregated context for test authoring — curates the most actionable learnings
|
|
398
509
|
* into a compact object that AI agents can use to write better tests.
|
|
399
510
|
*/
|
|
511
|
+
/**
|
|
512
|
+
* Action health scores — composite per-action metrics aggregated by (action_type, selector).
|
|
513
|
+
* Score = (success_rate * 0.5) + (speed_score * 0.3) + (collateral_score * 0.2)
|
|
514
|
+
*/
|
|
515
|
+
export function getActionHealthScores(projectId, days = 30) {
|
|
516
|
+
const d = getDb();
|
|
517
|
+
const rows = d.prepare(`
|
|
518
|
+
SELECT
|
|
519
|
+
action_type,
|
|
520
|
+
selector,
|
|
521
|
+
page_url,
|
|
522
|
+
COUNT(*) AS total_uses,
|
|
523
|
+
ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS success_rate,
|
|
524
|
+
ROUND(AVG(duration_ms)) AS avg_duration_ms,
|
|
525
|
+
MAX(duration_ms) AS max_duration_ms,
|
|
526
|
+
ROUND(AVG(console_errors_after + network_errors_after), 1) AS avg_collateral_errors,
|
|
527
|
+
COUNT(DISTINCT test_name) AS used_by_tests
|
|
528
|
+
FROM action_health
|
|
529
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
|
|
530
|
+
GROUP BY action_type, selector
|
|
531
|
+
HAVING total_uses >= 2
|
|
532
|
+
ORDER BY success_rate ASC, total_uses DESC
|
|
533
|
+
`).all(projectId, days);
|
|
534
|
+
|
|
535
|
+
return rows.map(r => {
|
|
536
|
+
const speedScore = 100 - Math.min(100, ((r.avg_duration_ms || 0) / 5000) * 100);
|
|
537
|
+
const collateralScore = 100 - Math.min(100, (r.avg_collateral_errors || 0) * 20);
|
|
538
|
+
const healthScore = Math.round(r.success_rate * 0.5 + speedScore * 0.3 + collateralScore * 0.2);
|
|
539
|
+
return {
|
|
540
|
+
actionType: r.action_type,
|
|
541
|
+
selector: r.selector,
|
|
542
|
+
pageUrl: r.page_url,
|
|
543
|
+
totalUses: r.total_uses,
|
|
544
|
+
successRate: r.success_rate,
|
|
545
|
+
avgDurationMs: r.avg_duration_ms,
|
|
546
|
+
maxDurationMs: r.max_duration_ms,
|
|
547
|
+
avgCollateralErrors: r.avg_collateral_errors,
|
|
548
|
+
usedByTests: r.used_by_tests,
|
|
549
|
+
healthScore,
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
400
554
|
export function getTestCreationContext(projectId) {
|
|
401
555
|
const d = getDb();
|
|
402
556
|
const ctx = {};
|
package/src/learner.js
CHANGED
|
@@ -17,8 +17,12 @@ const ERROR_CATEGORIES = [
|
|
|
17
17
|
{ pattern: /waitForSelector/i, category: 'selector-not-found' },
|
|
18
18
|
{ pattern: /not visible/i, category: 'selector-not-found' },
|
|
19
19
|
{ pattern: /navigation/i, category: 'navigation-error' },
|
|
20
|
-
{ pattern: /
|
|
20
|
+
{ pattern: /ERR_NAME_NOT_RESOLVED/i, category: 'dns-resolution' },
|
|
21
21
|
{ pattern: /ERR_CONNECTION_REFUSED/i, category: 'connection-refused' },
|
|
22
|
+
{ pattern: /ECONNREFUSED/i, category: 'connection-refused' },
|
|
23
|
+
{ pattern: /Chrome Pool unavailable/i, category: 'pool-unavailable' },
|
|
24
|
+
{ pattern: /Failed to connect to pool/i, category: 'pool-connect-failed' },
|
|
25
|
+
{ pattern: /net::ERR_/i, category: 'network-error' },
|
|
22
26
|
{ pattern: /assert_text/i, category: 'assert-text-failed' },
|
|
23
27
|
{ pattern: /assert_url/i, category: 'assert-url-failed' },
|
|
24
28
|
{ pattern: /assert_visible/i, category: 'assert-visible-failed' },
|
|
@@ -35,6 +39,18 @@ const ERROR_CATEGORIES = [
|
|
|
35
39
|
{ pattern: /evaluate.*ERROR/i, category: 'evaluate-error' },
|
|
36
40
|
];
|
|
37
41
|
|
|
42
|
+
/** Categories that indicate infrastructure failures — not test/app issues. */
|
|
43
|
+
export const INFRA_CATEGORIES = new Set([
|
|
44
|
+
'connection-refused', 'dns-resolution', 'pool-unavailable', 'pool-connect-failed', 'network-error',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Returns true if the error is an infrastructure issue (pool down, DNS, connection refused). */
|
|
48
|
+
export function isInfraError(errorMsg) {
|
|
49
|
+
if (!errorMsg) return false;
|
|
50
|
+
const { category } = categorizeError(errorMsg);
|
|
51
|
+
return INFRA_CATEGORIES.has(category);
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
export function categorizeError(errorMsg) {
|
|
39
55
|
if (!errorMsg) return { category: 'unknown', pattern: 'unknown' };
|
|
40
56
|
|
|
@@ -204,6 +220,11 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
|
|
|
204
220
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
205
221
|
`);
|
|
206
222
|
|
|
223
|
+
const insertActionHealth = d.prepare(`
|
|
224
|
+
INSERT INTO action_health (project_id, run_id, test_name, action_index, action_type, selector, success, duration_ms, console_errors_after, network_errors_after, page_url)
|
|
225
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
226
|
+
`);
|
|
227
|
+
|
|
207
228
|
const upsertErrorPattern = d.prepare(`
|
|
208
229
|
INSERT INTO error_patterns (project_id, pattern, category, occurrence_count, first_seen, last_seen, example_error, example_test)
|
|
209
230
|
VALUES (?, ?, ?, 1, datetime('now'), datetime('now'), ?, ?)
|
|
@@ -214,23 +235,40 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
|
|
|
214
235
|
example_test = excluded.example_test
|
|
215
236
|
`);
|
|
216
237
|
|
|
238
|
+
let infraCount = 0;
|
|
239
|
+
|
|
217
240
|
const tx = d.transaction(() => {
|
|
218
241
|
for (const result of results) {
|
|
219
242
|
const durationMs = (result.endTime && result.startTime)
|
|
220
243
|
? new Date(result.endTime) - new Date(result.startTime)
|
|
221
244
|
: null;
|
|
222
|
-
const isFlaky = result.success && (result.attempt || 1) > 1 ? 1 : 0;
|
|
245
|
+
const isFlaky = result.flaky ? 1 : (result.success && (result.attempt || 1) > 1 ? 1 : 0);
|
|
223
246
|
|
|
224
247
|
// Categorize error
|
|
225
248
|
let errorPattern = null;
|
|
249
|
+
let infraFailure = false;
|
|
226
250
|
if (result.error) {
|
|
227
251
|
const { category, pattern } = categorizeError(result.error);
|
|
228
252
|
errorPattern = category;
|
|
253
|
+
infraFailure = INFRA_CATEGORIES.has(category);
|
|
229
254
|
|
|
230
|
-
//
|
|
255
|
+
// Always track error patterns (even infra) for awareness
|
|
231
256
|
upsertErrorPattern.run(projectId, pattern, category, result.error, result.name);
|
|
232
257
|
}
|
|
233
258
|
|
|
259
|
+
if (infraFailure) {
|
|
260
|
+
infraCount++;
|
|
261
|
+
// Still write test_learnings so run counts are accurate,
|
|
262
|
+
// but skip selector/page/api learnings to avoid polluting metrics
|
|
263
|
+
insertTestLearning.run(
|
|
264
|
+
projectId, runDbId, result.name,
|
|
265
|
+
result.success ? 1 : 0, durationMs, isFlaky,
|
|
266
|
+
result.attempt || 1, result.maxAttempts || 1,
|
|
267
|
+
errorPattern
|
|
268
|
+
);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
234
272
|
// Test-level learning
|
|
235
273
|
insertTestLearning.run(
|
|
236
274
|
projectId, runDbId, result.name,
|
|
@@ -275,6 +313,33 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
|
|
|
275
313
|
api.isError, result.name
|
|
276
314
|
);
|
|
277
315
|
}
|
|
316
|
+
|
|
317
|
+
// Action health — per-action metrics with collateral error estimation
|
|
318
|
+
if (result.actions?.length) {
|
|
319
|
+
const totalConsoleErrors = (result.consoleLogs || []).filter(l => l.type === 'error').length;
|
|
320
|
+
const totalNetworkErrors = (result.networkErrors || []).length;
|
|
321
|
+
const actionCount = result.actions.length;
|
|
322
|
+
let currentPage = '/';
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < actionCount; i++) {
|
|
325
|
+
const action = result.actions[i];
|
|
326
|
+
if (action.type === 'goto' || action.type === 'navigate') {
|
|
327
|
+
try { currentPage = new URL(action.value, 'http://placeholder').pathname; } catch { currentPage = action.value || '/'; }
|
|
328
|
+
}
|
|
329
|
+
// Estimate collateral errors: later actions inherit more errors (weighted distribution)
|
|
330
|
+
const weight = (i + 1) / actionCount;
|
|
331
|
+
const consoleAfter = action.success === false ? Math.round(totalConsoleErrors * weight) : 0;
|
|
332
|
+
const networkAfter = action.success === false ? Math.round(totalNetworkErrors * weight) : 0;
|
|
333
|
+
|
|
334
|
+
insertActionHealth.run(
|
|
335
|
+
projectId, runDbId, result.name, i,
|
|
336
|
+
action.type || 'unknown', action.selector || null,
|
|
337
|
+
action.success === false ? 0 : 1,
|
|
338
|
+
action.duration || null,
|
|
339
|
+
consoleAfter, networkAfter, currentPage
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
278
343
|
}
|
|
279
344
|
});
|
|
280
345
|
|
|
@@ -287,6 +352,8 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
|
|
|
287
352
|
if (config?.learningsNeo4j) {
|
|
288
353
|
writeToGraph(projectId, runDbId, report, config, suiteName).catch(() => {});
|
|
289
354
|
}
|
|
355
|
+
|
|
356
|
+
return { infraCount };
|
|
290
357
|
}
|
|
291
358
|
|
|
292
359
|
// ── Summary cache ─────────────────────────────────────────────────────────────
|