@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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
@@ -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