@matware/e2e-runner 1.2.1 → 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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
package/src/pool.js CHANGED
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * Pool Management
3
3
  *
4
- * Connectivity to the Chrome Pool (browserless/chrome) and Docker Compose lifecycle.
4
+ * Connectivity to browser pools and Docker Compose lifecycle.
5
+ * Supports multiple pool drivers:
6
+ * - "browserless" — browserless/chrome with /pressure and /sessions HTTP API
7
+ * - "cdp" — generic CDP pool (Lightpanda, raw Chrome, etc.) using /json/version health check
8
+ * - "steel" — Steel Browser with /v1/sessions REST API and session lifecycle
9
+ * - "auto" — detect driver by probing endpoints: /pressure → browserless, /v1/sessions → steel, fallback → cdp
5
10
  */
6
11
 
7
12
  import puppeteer from 'puppeteer-core';
@@ -18,22 +23,267 @@ function sleep(ms) {
18
23
  return new Promise(resolve => setTimeout(resolve, ms));
19
24
  }
20
25
 
21
- /** Waits for the Chrome Pool to become available */
22
- export async function waitForPool(poolUrl, maxWaitMs = 30000) {
23
- const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
24
- const pressureUrl = `${poolHttpUrl}/pressure`;
26
+ // ── Driver detection cache ────────────────────────────────────────────────────
27
+
28
+ /** Caches detected driver per pool URL to avoid re-probing on every status call. */
29
+ const driverCache = new Map();
30
+
31
+ /** Clears the driver cache (useful for tests or pool restarts). */
32
+ export function clearDriverCache() {
33
+ driverCache.clear();
34
+ }
35
+
36
+ /** Returns the cached driver for a pool URL, or null if not yet detected. */
37
+ export function getCachedDriver(poolUrl) {
38
+ return driverCache.get(poolUrl) || null;
39
+ }
40
+
41
+ /**
42
+ * Detects the pool driver by probing HTTP endpoints.
43
+ * Probe order: /pressure (browserless) → /v1/sessions (steel) → fallback (cdp).
44
+ */
45
+ async function detectPoolDriver(poolUrl) {
46
+ if (driverCache.has(poolUrl)) return driverCache.get(poolUrl);
47
+
48
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
49
+
50
+ // Probe browserless
51
+ try {
52
+ const res = await fetch(`${httpUrl}/pressure`, { signal: AbortSignal.timeout(3000) });
53
+ if (res.ok) {
54
+ const data = await res.json();
55
+ if (data.pressure !== undefined) {
56
+ driverCache.set(poolUrl, 'browserless');
57
+ return 'browserless';
58
+ }
59
+ }
60
+ } catch { /* not browserless */ }
61
+
62
+ // Probe Steel
63
+ try {
64
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
65
+ if (res.ok) {
66
+ const data = await res.json();
67
+ if (data.sessions !== undefined) {
68
+ driverCache.set(poolUrl, 'steel');
69
+ return 'steel';
70
+ }
71
+ }
72
+ } catch { /* not steel */ }
73
+
74
+ // Fallback: generic CDP
75
+ driverCache.set(poolUrl, 'cdp');
76
+ return 'cdp';
77
+ }
78
+
79
+ /**
80
+ * Resolves the effective driver string.
81
+ * Maps config values: 'auto' → detect, 'lightpanda' → 'cdp', explicit → pass through.
82
+ */
83
+ async function resolveDriver(poolUrl, poolDriver) {
84
+ if (!poolDriver || poolDriver === 'auto') return detectPoolDriver(poolUrl);
85
+ if (poolDriver === 'lightpanda') return 'cdp';
86
+ return poolDriver;
87
+ }
88
+
89
+ // ── CDP driver: status via /json/version health check ─────────────────────────
90
+
91
+ /**
92
+ * Local session tracker for CDP pools (no remote management API).
93
+ * Maps poolUrl → Set of session IDs currently in use.
94
+ */
95
+ const cdpSessions = new Map();
96
+
97
+ export function trackCdpSession(poolUrl, sessionId) {
98
+ if (!cdpSessions.has(poolUrl)) cdpSessions.set(poolUrl, new Set());
99
+ cdpSessions.get(poolUrl).add(sessionId);
100
+ }
101
+
102
+ export function releaseCdpSession(poolUrl, sessionId) {
103
+ const sessions = cdpSessions.get(poolUrl);
104
+ if (sessions) {
105
+ sessions.delete(sessionId);
106
+ if (sessions.size === 0) cdpSessions.delete(poolUrl);
107
+ }
108
+ }
109
+
110
+ function getCdpSessionCount(poolUrl) {
111
+ return cdpSessions.get(poolUrl)?.size || 0;
112
+ }
113
+
114
+ async function getPoolStatusViaCDP(poolUrl, maxSessions) {
115
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
116
+ try {
117
+ const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
118
+ if (!res.ok) throw new Error(`/json/version returned ${res.status}`);
119
+
120
+ const running = getCdpSessionCount(poolUrl);
121
+ return {
122
+ available: running < maxSessions,
123
+ running,
124
+ maxConcurrent: maxSessions,
125
+ queued: 0,
126
+ sessions: [],
127
+ driver: 'cdp',
128
+ };
129
+ } catch (error) {
130
+ return {
131
+ available: false,
132
+ error: error.message,
133
+ running: 0,
134
+ maxConcurrent: maxSessions,
135
+ queued: 0,
136
+ sessions: [],
137
+ driver: 'cdp',
138
+ };
139
+ }
140
+ }
141
+
142
+ // ── Browserless driver: status via /pressure + /sessions ──────────────────────
143
+
144
+ async function getPoolStatusViaBrowserless(poolUrl) {
145
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
146
+ const [pressureRes, sessionsRes] = await Promise.all([
147
+ fetch(`${httpUrl}/pressure`),
148
+ fetch(`${httpUrl}/sessions`),
149
+ ]);
150
+
151
+ const pressure = pressureRes.ok ? await pressureRes.json() : null;
152
+ const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
153
+
154
+ return {
155
+ available: pressure?.pressure?.isAvailable ?? false,
156
+ running: pressure?.pressure?.running ?? 0,
157
+ maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
158
+ queued: pressure?.pressure?.queued ?? 0,
159
+ sessions: sessions || [],
160
+ driver: 'browserless',
161
+ };
162
+ }
163
+
164
+ // ── Steel driver: status via /v1/sessions REST API ────────────────────────────
165
+
166
+ /**
167
+ * Tracks Steel session IDs created by this process so we can release them.
168
+ * Maps poolUrl → Map<browserId, steelSessionId>.
169
+ */
170
+ const steelSessionMap = new Map();
171
+
172
+ function trackSteelSession(poolUrl, browserId, steelSessionId) {
173
+ if (!steelSessionMap.has(poolUrl)) steelSessionMap.set(poolUrl, new Map());
174
+ steelSessionMap.get(poolUrl).set(browserId, steelSessionId);
175
+ }
176
+
177
+ function getSteelSessionId(poolUrl, browserId) {
178
+ return steelSessionMap.get(poolUrl)?.get(browserId) || null;
179
+ }
180
+
181
+ function removeSteelSession(poolUrl, browserId) {
182
+ const sessions = steelSessionMap.get(poolUrl);
183
+ if (sessions) {
184
+ sessions.delete(browserId);
185
+ if (sessions.size === 0) steelSessionMap.delete(poolUrl);
186
+ }
187
+ }
188
+
189
+ async function getPoolStatusViaSteel(poolUrl, maxSessions) {
190
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
191
+ try {
192
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
193
+ if (!res.ok) throw new Error(`/v1/sessions returned ${res.status}`);
194
+ const data = await res.json();
195
+ const activeSessions = (data.sessions || []).filter(s => s.status === 'live' || s.status === 'idle');
196
+ return {
197
+ available: activeSessions.length < maxSessions,
198
+ running: activeSessions.length,
199
+ maxConcurrent: maxSessions,
200
+ queued: 0,
201
+ sessions: activeSessions.map(s => ({ id: s.id, status: s.status, duration: s.duration })),
202
+ driver: 'steel',
203
+ };
204
+ } catch (error) {
205
+ return {
206
+ available: false,
207
+ error: error.message,
208
+ running: 0,
209
+ maxConcurrent: maxSessions,
210
+ queued: 0,
211
+ sessions: [],
212
+ driver: 'steel',
213
+ };
214
+ }
215
+ }
216
+
217
+ /** Creates a Steel session and connects Puppeteer to it. */
218
+ async function connectToSteelPool(poolUrl, retries = 3, delay = 2000) {
219
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
220
+
221
+ for (let attempt = 1; attempt <= retries; attempt++) {
222
+ try {
223
+ // Create a new Steel session
224
+ const sessionRes = await fetch(`${httpUrl}/v1/sessions`, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({}),
228
+ signal: AbortSignal.timeout(15000),
229
+ });
230
+ if (!sessionRes.ok) throw new Error(`Steel session creation failed: ${sessionRes.status}`);
231
+ const session = await sessionRes.json();
232
+
233
+ // Rewrite the internal WS URL (0.0.0.0:3000) to match our host:port
234
+ const wsUrl = poolUrl.endsWith('/') ? poolUrl : poolUrl + '/';
235
+
236
+ const browser = await puppeteer.connect({
237
+ browserWSEndpoint: wsUrl,
238
+ timeout: 30000,
239
+ });
240
+
241
+ // Track session for cleanup
242
+ const browserId = browser.wsEndpoint();
243
+ trackSteelSession(poolUrl, browserId, session.id);
244
+
245
+ return browser;
246
+ } catch (error) {
247
+ if (attempt === retries) {
248
+ throw new Error(`Failed to connect to Steel pool: ${error.message}`);
249
+ }
250
+ log('🔄', `Attempt ${attempt}/${retries} failed, retrying...`);
251
+ await sleep(delay);
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Releases a Steel session after browser disconnect.
258
+ * Call this in the finally block of test execution.
259
+ */
260
+ export async function releaseSteelSession(poolUrl, browser) {
261
+ if (!browser) return;
262
+ const browserId = browser.wsEndpoint();
263
+ const sessionId = getSteelSessionId(poolUrl, browserId);
264
+ if (!sessionId) return;
265
+
266
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
267
+ try {
268
+ await fetch(`${httpUrl}/v1/sessions/${sessionId}/release`, {
269
+ method: 'POST',
270
+ signal: AbortSignal.timeout(5000),
271
+ });
272
+ } catch { /* best effort */ }
273
+ removeSteelSession(poolUrl, browserId);
274
+ }
275
+
276
+ // ── Public API ────────────────────────────────────────────────────────────────
277
+
278
+ /** Waits for the pool to become available */
279
+ export async function waitForPool(poolUrl, maxWaitMs = 30000, options = {}) {
25
280
  const start = Date.now();
26
281
 
27
282
  while (Date.now() - start < maxWaitMs) {
28
283
  try {
29
- const res = await fetch(pressureUrl);
30
- if (res.ok) {
31
- const data = await res.json();
32
- if (data.pressure?.isAvailable) {
33
- return data.pressure;
34
- }
35
- log('⏳', `Pool busy (${data.pressure.running}/${data.pressure.maxConcurrent}), waiting...`);
36
- }
284
+ const status = await getPoolStatus(poolUrl, options);
285
+ if (status.available) return status;
286
+ log('⏳', `Pool busy (${status.running}/${status.maxConcurrent}), waiting...`);
37
287
  } catch {
38
288
  // Pool not ready
39
289
  }
@@ -42,8 +292,13 @@ export async function waitForPool(poolUrl, maxWaitMs = 30000) {
42
292
  throw new Error(`Chrome Pool unavailable after ${maxWaitMs / 1000}s. Verify the container is running.`);
43
293
  }
44
294
 
45
- /** Connects to the pool with retries */
295
+ /** Connects to the pool with retries. For Steel pools, creates a session first. */
46
296
  export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
297
+ const driver = getCachedDriver(poolUrl);
298
+ if (driver === 'steel') {
299
+ return connectToSteelPool(poolUrl, retries, delay);
300
+ }
301
+
47
302
  for (let attempt = 1; attempt <= retries; attempt++) {
48
303
  try {
49
304
  return await puppeteer.connect({
@@ -60,6 +315,19 @@ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
60
315
  }
61
316
  }
62
317
 
318
+ /**
319
+ * Disconnects from a pool, releasing any driver-specific resources.
320
+ * For Steel pools, releases the session via REST API.
321
+ */
322
+ export async function disconnectFromPool(browser, poolUrl) {
323
+ if (!browser) return;
324
+ const driver = getCachedDriver(poolUrl);
325
+ if (driver === 'steel') {
326
+ await releaseSteelSession(poolUrl, browser);
327
+ }
328
+ try { await browser.disconnect(); } catch { /* */ }
329
+ }
330
+
63
331
  /** Generates docker-compose.yml and starts the pool */
64
332
  export function startPool(config, cwd = null) {
65
333
  cwd = cwd || process.cwd();
@@ -112,26 +380,27 @@ export function restartPool(config, cwd = null) {
112
380
  startPool(config, cwd);
113
381
  }
114
382
 
115
- /** Gets pool status */
116
- export async function getPoolStatus(poolUrl) {
117
- const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
383
+ /**
384
+ * Gets pool status using the appropriate driver.
385
+ * @param {string} poolUrl - WebSocket URL of the pool
386
+ * @param {object} [options] - { poolDriver: 'auto'|'browserless'|'lightpanda'|'cdp'|'steel', maxSessions: number }
387
+ */
388
+ export async function getPoolStatus(poolUrl, options = {}) {
389
+ const { poolDriver = 'auto', maxSessions = 10 } = options;
118
390
 
119
- try {
120
- const [pressureRes, sessionsRes] = await Promise.all([
121
- fetch(`${poolHttpUrl}/pressure`),
122
- fetch(`${poolHttpUrl}/sessions`),
123
- ]);
391
+ const driver = await resolveDriver(poolUrl, poolDriver);
124
392
 
125
- const pressure = pressureRes.ok ? await pressureRes.json() : null;
126
- const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
393
+ if (driver === 'steel') {
394
+ return getPoolStatusViaSteel(poolUrl, maxSessions);
395
+ }
127
396
 
128
- return {
129
- available: pressure?.pressure?.isAvailable ?? false,
130
- running: pressure?.pressure?.running ?? 0,
131
- maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
132
- queued: pressure?.pressure?.queued ?? 0,
133
- sessions: sessions || [],
134
- };
397
+ if (driver === 'cdp') {
398
+ return getPoolStatusViaCDP(poolUrl, maxSessions);
399
+ }
400
+
401
+ // Browserless driver
402
+ try {
403
+ return await getPoolStatusViaBrowserless(poolUrl);
135
404
  } catch (error) {
136
405
  return {
137
406
  available: false,
@@ -140,6 +409,7 @@ export async function getPoolStatus(poolUrl) {
140
409
  maxConcurrent: 0,
141
410
  queued: 0,
142
411
  sessions: [],
412
+ driver: 'browserless',
143
413
  };
144
414
  }
145
415
  }
package/src/reporter.js CHANGED
@@ -9,6 +9,8 @@ import { ensureProject, saveRun as saveRunToDb } from './db.js';
9
9
  import { narrateTest } from './narrate.js';
10
10
  import { learnFromRun } from './learner.js';
11
11
  import { generateLearningsMarkdown } from './learner-markdown.js';
12
+ import { getHealthSnapshot, getRunInsights } from './learner-sqlite.js';
13
+ import { pushRun as syncPushRun } from './sync/client.js';
12
14
 
13
15
  function escapeXml(str) {
14
16
  return String(str)
@@ -150,13 +152,16 @@ export function loadHistoryRun(screenshotsDir, runId) {
150
152
  }
151
153
 
152
154
  /** Persists a run to both filesystem history and SQLite (never throws). */
153
- export function persistRun(report, config, suiteName) {
155
+ export async function persistRun(report, config, suiteName) {
154
156
  const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
155
157
  let runDbId = null;
156
158
 
157
159
  try {
158
160
  const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
159
- runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
161
+ // Derive actual pool driver from test results (resolves 'auto' to real driver)
162
+ const drivers = [...new Set((report.results || []).map(r => r.poolDriver).filter(Boolean))];
163
+ const resolvedDriver = drivers.length === 1 ? drivers[0] : drivers.length > 1 ? drivers.join(',') : config.poolDriver || null;
164
+ runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null, resolvedDriver);
160
165
 
161
166
  // Fire-and-forget: learn from this run (never blocks or crashes the runner)
162
167
  if (config.learningsEnabled !== false) {
@@ -175,6 +180,17 @@ export function persistRun(report, config, suiteName) {
175
180
  }
176
181
  }
177
182
  }
183
+
184
+ // Sync push if in agent mode with autoSync enabled
185
+ if (config.sync?.mode === 'agent' && config.sync?.agent?.autoSync !== false) {
186
+ try {
187
+ const project = { name: config.projectName, slug: config.projectName.toLowerCase().replace(/[^a-z0-9]+/g, '-') };
188
+ const enrichedReport = { ...report, runId, suiteName, triggeredBy: config.triggeredBy };
189
+ await syncPushRun(config, project, enrichedReport);
190
+ } catch (syncErr) {
191
+ process.stderr.write(`[e2e-runner] Sync push failed: ${syncErr.message}\n`);
192
+ }
193
+ }
178
194
  } catch (err) {
179
195
  process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
180
196
  }
@@ -260,3 +276,71 @@ export function printReport(report, screenshotsDir) {
260
276
  console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
261
277
  }
262
278
  }
279
+
280
+ /** Prints a compact learnings/health block after the run report. Never throws. */
281
+ export function printInsights(report, config) {
282
+ try {
283
+ if (config.learningsEnabled === false) return;
284
+
285
+ const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
286
+ const health = getHealthSnapshot(projectId);
287
+ const insights = getRunInsights(projectId, report);
288
+
289
+ // Nothing to show if no historical data and no insights
290
+ if (!health && insights.length === 0) return;
291
+
292
+ const lines = [];
293
+ const LINE = `${C.dim}${'─'.repeat(42)}${C.reset}`;
294
+
295
+ // Run-specific insights
296
+ const newFailures = insights.filter(i => i.type === 'new-failure');
297
+ const flaky = insights.filter(i => i.type === 'flaky');
298
+ const recovered = insights.filter(i => i.type === 'recovered');
299
+ const unstable = insights.find(i => i.type === 'unstable-selectors');
300
+
301
+ if (newFailures.length > 0) {
302
+ lines.push(` ${C.red}!${C.reset} ${newFailures.length} new failure(s) (previously stable)`);
303
+ for (const f of newFailures.slice(0, 3)) {
304
+ lines.push(` ${C.dim}- ${f.test}${C.reset}`);
305
+ }
306
+ if (newFailures.length > 3) lines.push(` ${C.dim}... and ${newFailures.length - 3} more${C.reset}`);
307
+ }
308
+ if (recovered.length > 0) {
309
+ lines.push(` ${C.green}+${C.reset} ${recovered.length} recovered test(s)`);
310
+ }
311
+ if (flaky.length > 0) {
312
+ lines.push(` ${C.yellow}~${C.reset} ${flaky.length} known flaky test(s) passed this time`);
313
+ }
314
+ if (unstable) {
315
+ const sels = unstable.selectors.slice(0, 3).join(', ');
316
+ lines.push(` ${C.red}!${C.reset} ${unstable.selectors.length} unstable selector(s): ${C.dim}${sels}${C.reset}`);
317
+ }
318
+
319
+ // Health snapshot
320
+ if (health) {
321
+ const rateColor = health.passRate >= 90 ? C.green : health.passRate >= 70 ? C.yellow : C.red;
322
+ const trendIcon = health.passRateTrend === 'improving' ? `${C.green}^${C.reset}` : health.passRateTrend === 'declining' ? `${C.red}v${C.reset}` : `${C.dim}=${C.reset}`;
323
+ const deltaStr = health.trendDelta !== 0 ? `, ${health.trendDelta > 0 ? '+' : ''}${health.trendDelta}%` : '';
324
+ lines.push(` ${trendIcon} Pass rate: ${rateColor}${health.passRate}%${C.reset} (${health.passRateTrend}${deltaStr})`);
325
+
326
+ if (health.topErrorPattern) {
327
+ const cat = health.topErrorPattern.category || health.topErrorPattern.pattern || 'unknown';
328
+ const label = cat.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
329
+ lines.push(` ${C.dim}!${C.reset} Top error: ${C.dim}${label} (${health.topErrorPattern.count}x)${C.reset}`);
330
+ }
331
+ }
332
+
333
+ if (lines.length === 0) return;
334
+
335
+ console.log('');
336
+ console.log(`${C.dim}── ${C.reset}${C.bold}Learnings${C.reset} ${LINE}`);
337
+ for (const line of lines) console.log(line);
338
+ console.log(` ${C.dim}Run 'e2e-runner learnings' for full details${C.reset}`);
339
+ if (config.learningsMarkdown !== false) {
340
+ console.log(` ${C.dim}Updated: e2e/learnings.md${C.reset}`);
341
+ }
342
+ console.log(LINE);
343
+ } catch {
344
+ // Never fail the run
345
+ }
346
+ }