@matware/e2e-runner 1.2.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/.mcp.json +2 -2
- 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 +581 -55
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +408 -16
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- 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 +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +324 -2
- package/src/ai-generate.js +58 -8
- package/src/config.js +143 -0
- package/src/dashboard.js +145 -13
- package/src/db.js +130 -2
- package/src/index.js +7 -6
- package/src/learner-sqlite.js +304 -0
- package/src/learner.js +8 -3
- package/src/mcp-tools.js +1121 -43
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +37 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +82 -1
- package/src/runner.js +157 -28
- 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 +10 -7
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- 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 +165 -99
- package/templates/dashboard.html +1596 -541
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/learner-sqlite.js
CHANGED
|
@@ -296,6 +296,46 @@ export function getRunInsights(projectId, report) {
|
|
|
296
296
|
return insights;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Compact health snapshot for a project — used by CLI, MCP, and Dashboard.
|
|
301
|
+
* Returns null if no historical data exists.
|
|
302
|
+
*/
|
|
303
|
+
export function getHealthSnapshot(projectId) {
|
|
304
|
+
const summary = getLearningsSummary(projectId);
|
|
305
|
+
if (!summary || summary.totalRuns === 0) return null;
|
|
306
|
+
|
|
307
|
+
const flakyCount = summary.flakyTests ? summary.flakyTests.length : 0;
|
|
308
|
+
const unstableSelectorCount = summary.unstableSelectors ? summary.unstableSelectors.length : 0;
|
|
309
|
+
const topError = summary.topErrors && summary.topErrors.length > 0
|
|
310
|
+
? { pattern: summary.topErrors[0].pattern, count: summary.topErrors[0].occurrence_count, category: summary.topErrors[0].category }
|
|
311
|
+
: null;
|
|
312
|
+
|
|
313
|
+
// Compute trend from recent daily data
|
|
314
|
+
let passRateTrend = 'stable'; // 'improving', 'declining', 'stable'
|
|
315
|
+
let trendDelta = 0;
|
|
316
|
+
|
|
317
|
+
const trends = getTestTrends(projectId, 7);
|
|
318
|
+
const trendData = trends?.data || trends || [];
|
|
319
|
+
if (Array.isArray(trendData) && trendData.length >= 2) {
|
|
320
|
+
const recent = trendData[trendData.length - 1].pass_rate;
|
|
321
|
+
const prior = trendData.slice(0, -1).reduce((s, t) => s + t.pass_rate, 0) / (trendData.length - 1);
|
|
322
|
+
trendDelta = Math.round((recent - prior) * 10) / 10;
|
|
323
|
+
if (trendDelta > 2) passRateTrend = 'improving';
|
|
324
|
+
else if (trendDelta < -2) passRateTrend = 'declining';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
passRate: summary.overallPassRate,
|
|
329
|
+
passRateTrend,
|
|
330
|
+
trendDelta,
|
|
331
|
+
flakyCount,
|
|
332
|
+
unstableSelectorCount,
|
|
333
|
+
topErrorPattern: topError,
|
|
334
|
+
totalRuns: summary.totalRuns,
|
|
335
|
+
totalTests: summary.totalTests,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
299
339
|
/** Drill-down: history for a specific test. */
|
|
300
340
|
export function getTestHistory(projectId, testName, days = 30) {
|
|
301
341
|
const d = getDb();
|
|
@@ -352,3 +392,267 @@ export function getSelectorHistory(projectId, selector, days = 30) {
|
|
|
352
392
|
ORDER BY created_at DESC
|
|
353
393
|
`).all(projectId, selector, days);
|
|
354
394
|
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Aggregated context for test authoring — curates the most actionable learnings
|
|
398
|
+
* into a compact object that AI agents can use to write better tests.
|
|
399
|
+
*/
|
|
400
|
+
export function getTestCreationContext(projectId) {
|
|
401
|
+
const d = getDb();
|
|
402
|
+
const ctx = {};
|
|
403
|
+
|
|
404
|
+
// Top 5 unstable selectors (>20% fail rate)
|
|
405
|
+
const unstable = d.prepare(`
|
|
406
|
+
SELECT
|
|
407
|
+
selector,
|
|
408
|
+
ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
|
|
409
|
+
MAX(CASE WHEN success = 0 THEN error END) AS last_error,
|
|
410
|
+
COUNT(*) AS total_uses
|
|
411
|
+
FROM selector_learnings
|
|
412
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
413
|
+
GROUP BY selector
|
|
414
|
+
HAVING fail_rate > 20
|
|
415
|
+
ORDER BY fail_rate DESC
|
|
416
|
+
LIMIT 5
|
|
417
|
+
`).all(projectId);
|
|
418
|
+
|
|
419
|
+
if (unstable.length > 0) {
|
|
420
|
+
ctx.unstableSelectors = unstable.map(s => ({
|
|
421
|
+
selector: s.selector,
|
|
422
|
+
failRate: s.fail_rate,
|
|
423
|
+
lastError: s.last_error,
|
|
424
|
+
suggestion: suggestSelectorFix(s.selector),
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Top 10 stable selectors (0% fail rate, >5 uses)
|
|
429
|
+
const stable = d.prepare(`
|
|
430
|
+
SELECT
|
|
431
|
+
selector,
|
|
432
|
+
COUNT(*) AS total_uses,
|
|
433
|
+
COUNT(DISTINCT test_name) AS used_by_tests
|
|
434
|
+
FROM selector_learnings
|
|
435
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
436
|
+
GROUP BY selector
|
|
437
|
+
HAVING total_uses > 5 AND SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0
|
|
438
|
+
ORDER BY total_uses DESC
|
|
439
|
+
LIMIT 10
|
|
440
|
+
`).all(projectId);
|
|
441
|
+
|
|
442
|
+
if (stable.length > 0) {
|
|
443
|
+
ctx.stableSelectors = stable.map(s => ({
|
|
444
|
+
selector: s.selector,
|
|
445
|
+
uses: s.total_uses,
|
|
446
|
+
tests: s.used_by_tests,
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Top 5 error patterns
|
|
451
|
+
const errors = d.prepare(`
|
|
452
|
+
SELECT pattern, category, occurrence_count
|
|
453
|
+
FROM error_patterns
|
|
454
|
+
WHERE project_id = ?
|
|
455
|
+
ORDER BY occurrence_count DESC
|
|
456
|
+
LIMIT 5
|
|
457
|
+
`).all(projectId);
|
|
458
|
+
|
|
459
|
+
if (errors.length > 0) {
|
|
460
|
+
ctx.errorPatterns = errors.map(e => ({
|
|
461
|
+
pattern: e.pattern,
|
|
462
|
+
category: e.category,
|
|
463
|
+
count: e.occurrence_count,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Slow pages (avg load > 3s)
|
|
468
|
+
const slowPages = d.prepare(`
|
|
469
|
+
SELECT
|
|
470
|
+
url_path,
|
|
471
|
+
ROUND(AVG(load_time_ms)) AS avg_load_ms
|
|
472
|
+
FROM page_learnings
|
|
473
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
474
|
+
GROUP BY url_path
|
|
475
|
+
HAVING avg_load_ms > 3000
|
|
476
|
+
ORDER BY avg_load_ms DESC
|
|
477
|
+
LIMIT 5
|
|
478
|
+
`).all(projectId);
|
|
479
|
+
|
|
480
|
+
if (slowPages.length > 0) {
|
|
481
|
+
ctx.slowPages = slowPages.map(p => ({
|
|
482
|
+
page: p.url_path,
|
|
483
|
+
avgLoadMs: p.avg_load_ms,
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Flaky tests
|
|
488
|
+
const flaky = d.prepare(`
|
|
489
|
+
SELECT test_name, SUM(flaky) AS flaky_count, COUNT(*) AS total_runs
|
|
490
|
+
FROM test_learnings
|
|
491
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
492
|
+
GROUP BY test_name
|
|
493
|
+
HAVING flaky_count > 0
|
|
494
|
+
ORDER BY flaky_count DESC
|
|
495
|
+
LIMIT 5
|
|
496
|
+
`).all(projectId);
|
|
497
|
+
|
|
498
|
+
if (flaky.length > 0) {
|
|
499
|
+
ctx.flakyTests = flaky.map(f => ({
|
|
500
|
+
name: f.test_name,
|
|
501
|
+
flakyCount: f.flaky_count,
|
|
502
|
+
totalRuns: f.total_runs,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// API endpoints with >10% error rate
|
|
507
|
+
const apiIssues = d.prepare(`
|
|
508
|
+
SELECT
|
|
509
|
+
endpoint,
|
|
510
|
+
ROUND(AVG(CASE WHEN is_error = 1 THEN 100.0 ELSE 0.0 END), 1) AS error_rate,
|
|
511
|
+
COUNT(*) AS total_calls
|
|
512
|
+
FROM api_learnings
|
|
513
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
514
|
+
GROUP BY endpoint
|
|
515
|
+
HAVING error_rate > 10
|
|
516
|
+
ORDER BY error_rate DESC
|
|
517
|
+
LIMIT 5
|
|
518
|
+
`).all(projectId);
|
|
519
|
+
|
|
520
|
+
if (apiIssues.length > 0) {
|
|
521
|
+
ctx.apiIssues = apiIssues.map(a => ({
|
|
522
|
+
endpoint: a.endpoint,
|
|
523
|
+
errorRate: a.error_rate,
|
|
524
|
+
totalCalls: a.total_calls,
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Overall pass rate
|
|
529
|
+
const stats = d.prepare(`
|
|
530
|
+
SELECT
|
|
531
|
+
COUNT(*) AS total_tests,
|
|
532
|
+
ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate
|
|
533
|
+
FROM test_learnings
|
|
534
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
535
|
+
`).get(projectId);
|
|
536
|
+
|
|
537
|
+
if (stats && stats.total_tests > 0) {
|
|
538
|
+
ctx.passRate = stats.pass_rate;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return Object.keys(ctx).length > 0 ? ctx : null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Suggest a fix for an unstable selector based on its pattern. */
|
|
545
|
+
function suggestSelectorFix(selector) {
|
|
546
|
+
if (/^\.Mui|^\.css-|^\.sc-/.test(selector)) return 'Prefer [data-testid] or click by text — generated class names are brittle';
|
|
547
|
+
if (/\s>\s/.test(selector) && selector.split('>').length > 3) return 'Deeply nested selector — simplify or use [data-testid]';
|
|
548
|
+
if (/nth-child|nth-of-type/.test(selector)) return 'Positional selector — prefer [data-testid] or text-based selection';
|
|
549
|
+
return 'Consider using [data-testid] or a more stable selector';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Cross-reference a run report with historical learnings to produce actionable
|
|
554
|
+
* improvement suggestions for the AI agent.
|
|
555
|
+
*/
|
|
556
|
+
export function generateImprovements(projectId, report) {
|
|
557
|
+
const d = getDb();
|
|
558
|
+
const improvements = [];
|
|
559
|
+
|
|
560
|
+
if (!report?.results) return improvements;
|
|
561
|
+
|
|
562
|
+
// Build a map of stable alternatives for unstable selectors
|
|
563
|
+
const stableAlts = d.prepare(`
|
|
564
|
+
SELECT selector, COUNT(*) AS uses
|
|
565
|
+
FROM selector_learnings
|
|
566
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
567
|
+
GROUP BY selector
|
|
568
|
+
HAVING uses > 3 AND SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0
|
|
569
|
+
ORDER BY uses DESC
|
|
570
|
+
`).all(projectId);
|
|
571
|
+
|
|
572
|
+
const stableSet = new Set(stableAlts.map(s => s.selector));
|
|
573
|
+
|
|
574
|
+
// Unstable selectors with their fail rates
|
|
575
|
+
const unstableMap = new Map();
|
|
576
|
+
const unstableRows = d.prepare(`
|
|
577
|
+
SELECT
|
|
578
|
+
selector,
|
|
579
|
+
ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate
|
|
580
|
+
FROM selector_learnings
|
|
581
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
582
|
+
GROUP BY selector
|
|
583
|
+
HAVING fail_rate > 20
|
|
584
|
+
`).all(projectId);
|
|
585
|
+
for (const row of unstableRows) unstableMap.set(row.selector, row.fail_rate);
|
|
586
|
+
|
|
587
|
+
// Flaky test counts
|
|
588
|
+
const flakyMap = new Map();
|
|
589
|
+
const flakyRows = d.prepare(`
|
|
590
|
+
SELECT test_name, SUM(flaky) AS flaky_count
|
|
591
|
+
FROM test_learnings
|
|
592
|
+
WHERE project_id = ? AND created_at >= datetime('now', '-30 days')
|
|
593
|
+
GROUP BY test_name
|
|
594
|
+
HAVING flaky_count > 0
|
|
595
|
+
`).all(projectId);
|
|
596
|
+
for (const row of flakyRows) flakyMap.set(row.test_name, row.flaky_count);
|
|
597
|
+
|
|
598
|
+
for (const result of report.results) {
|
|
599
|
+
// Failed selector suggestions — find stable alternatives on the same page
|
|
600
|
+
if (!result.success && result.error) {
|
|
601
|
+
const selectorMatch = result.error.match(/selector ["']([^"']+)["']/i)
|
|
602
|
+
|| result.error.match(/waiting for selector (.+)/i);
|
|
603
|
+
if (selectorMatch) {
|
|
604
|
+
const failedSelector = selectorMatch[1];
|
|
605
|
+
const failRate = unstableMap.get(failedSelector);
|
|
606
|
+
if (failRate) {
|
|
607
|
+
improvements.push({
|
|
608
|
+
type: 'unstable-selector',
|
|
609
|
+
test: result.name,
|
|
610
|
+
message: `Selector \`${failedSelector}\` failed (${failRate}% historical fail rate) → ${suggestSelectorFix(failedSelector)}`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Timeout suggestions
|
|
616
|
+
if (/timeout|timed?\s*out/i.test(result.error)) {
|
|
617
|
+
improvements.push({
|
|
618
|
+
type: 'timeout',
|
|
619
|
+
test: result.name,
|
|
620
|
+
message: `Test "${result.name}" timed out → add explicit { type: "wait", text: "..." } or increase timeout`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Check for tests using known unstable selectors (even if they passed this time)
|
|
626
|
+
if (result.actions) {
|
|
627
|
+
for (const action of result.actions) {
|
|
628
|
+
if (action.selector && unstableMap.has(action.selector)) {
|
|
629
|
+
const failRate = unstableMap.get(action.selector);
|
|
630
|
+
improvements.push({
|
|
631
|
+
type: 'at-risk-selector',
|
|
632
|
+
test: result.name,
|
|
633
|
+
message: `Selector \`${action.selector}\` has ${failRate}% fail rate → ${suggestSelectorFix(action.selector)}`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Flaky test suggestions
|
|
640
|
+
const flakyCount = flakyMap.get(result.name);
|
|
641
|
+
if (flakyCount && flakyCount >= 2) {
|
|
642
|
+
improvements.push({
|
|
643
|
+
type: 'flaky',
|
|
644
|
+
test: result.name,
|
|
645
|
+
message: `Test "${result.name}" is flaky (${flakyCount} flaky runs) → add { retries: 2 } to the test config`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Deduplicate by type+test (keep first occurrence)
|
|
651
|
+
const seen = new Set();
|
|
652
|
+
return improvements.filter(imp => {
|
|
653
|
+
const key = `${imp.type}:${imp.test}:${imp.message.slice(0, 60)}`;
|
|
654
|
+
if (seen.has(key)) return false;
|
|
655
|
+
seen.add(key);
|
|
656
|
+
return true;
|
|
657
|
+
});
|
|
658
|
+
}
|
package/src/learner.js
CHANGED
|
@@ -335,8 +335,11 @@ function updateLearningSummary(projectId, config) {
|
|
|
335
335
|
// Unstable selectors
|
|
336
336
|
const unstableSelectors = d.prepare(`
|
|
337
337
|
SELECT selector,
|
|
338
|
+
MAX(action_type) AS action_type,
|
|
338
339
|
ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
|
|
339
|
-
COUNT(*) AS total_uses
|
|
340
|
+
COUNT(*) AS total_uses,
|
|
341
|
+
COUNT(DISTINCT test_name) AS used_by_tests,
|
|
342
|
+
MAX(page_url) AS page_url
|
|
340
343
|
FROM selector_learnings
|
|
341
344
|
WHERE project_id = ? AND created_at >= ${cutoff}
|
|
342
345
|
GROUP BY selector
|
|
@@ -349,6 +352,7 @@ function updateLearningSummary(projectId, config) {
|
|
|
349
352
|
const failingPages = d.prepare(`
|
|
350
353
|
SELECT url_path,
|
|
351
354
|
ROUND(AVG(CASE WHEN success = 0 THEN 100.0 ELSE 0.0 END), 1) AS fail_rate,
|
|
355
|
+
COUNT(*) AS total_visits,
|
|
352
356
|
SUM(console_errors) AS console_errors,
|
|
353
357
|
SUM(network_errors) AS network_errors
|
|
354
358
|
FROM page_learnings
|
|
@@ -364,7 +368,8 @@ function updateLearningSummary(projectId, config) {
|
|
|
364
368
|
SELECT endpoint,
|
|
365
369
|
ROUND(AVG(CASE WHEN is_error = 1 THEN 100.0 ELSE 0.0 END), 1) AS error_rate,
|
|
366
370
|
ROUND(AVG(duration_ms)) AS avg_duration_ms,
|
|
367
|
-
COUNT(*) AS total_calls
|
|
371
|
+
COUNT(*) AS total_calls,
|
|
372
|
+
GROUP_CONCAT(DISTINCT status) AS status_codes
|
|
368
373
|
FROM api_learnings
|
|
369
374
|
WHERE project_id = ? AND created_at >= ${cutoff}
|
|
370
375
|
GROUP BY endpoint
|
|
@@ -375,7 +380,7 @@ function updateLearningSummary(projectId, config) {
|
|
|
375
380
|
|
|
376
381
|
// Top errors
|
|
377
382
|
const topErrors = d.prepare(`
|
|
378
|
-
SELECT pattern, category, occurrence_count, last_seen, example_error
|
|
383
|
+
SELECT pattern, category, occurrence_count, first_seen, last_seen, example_error AS example_test
|
|
379
384
|
FROM error_patterns
|
|
380
385
|
WHERE project_id = ?
|
|
381
386
|
ORDER BY occurrence_count DESC
|