@matware/e2e-runner 1.1.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/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- 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 +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- 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 +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- 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 +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- 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 +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- package/templates/sample-test.json +0 -8
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool Manager — multi-pool selection and distribution.
|
|
3
|
+
*
|
|
4
|
+
* Abstracts pool selection behind a least-pressure strategy.
|
|
5
|
+
* When multiple pools are configured, tests are distributed across
|
|
6
|
+
* all available Chrome capacity. Single-pool setups work identically.
|
|
7
|
+
*
|
|
8
|
+
* Uses a local pending counter to avoid "thundering herd" — when many
|
|
9
|
+
* workers call selectPool() simultaneously, the remote /pressure endpoint
|
|
10
|
+
* hasn't updated yet. The pending map tracks selections locally so
|
|
11
|
+
* subsequent calls factor in connections that are in-flight.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getPoolStatus, connectToPool } from './pool.js';
|
|
15
|
+
import { log, colors as C } from './logger.js';
|
|
16
|
+
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Local pending counter — tracks connections selected but not yet
|
|
23
|
+
* reflected in the pool's /pressure endpoint. Prevents all workers
|
|
24
|
+
* from picking the same pool when they query simultaneously.
|
|
25
|
+
*/
|
|
26
|
+
const pendingConnections = new Map();
|
|
27
|
+
|
|
28
|
+
export function trackPending(poolUrl) {
|
|
29
|
+
pendingConnections.set(poolUrl, (pendingConnections.get(poolUrl) || 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function releasePending(poolUrl) {
|
|
33
|
+
const current = pendingConnections.get(poolUrl) || 0;
|
|
34
|
+
if (current > 1) {
|
|
35
|
+
pendingConnections.set(poolUrl, current - 1);
|
|
36
|
+
} else {
|
|
37
|
+
pendingConnections.delete(poolUrl);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPending(poolUrl) {
|
|
42
|
+
return pendingConnections.get(poolUrl) || 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns the normalized pool URL array from config. Always an array, even for single pool. */
|
|
46
|
+
export function getPoolUrls(config) {
|
|
47
|
+
return config._poolUrls || [config.poolUrl];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fetches /pressure from all pools in parallel. Returns [{ url, status, error }]. */
|
|
51
|
+
export async function getAllPoolStatuses(poolUrls) {
|
|
52
|
+
return Promise.all(poolUrls.map(async (url) => {
|
|
53
|
+
try {
|
|
54
|
+
const status = await getPoolStatus(url);
|
|
55
|
+
return { url, status, error: null };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { url, status: null, error: error.message };
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Combined view across all pools: totalRunning, totalMaxConcurrent, per-pool details. */
|
|
63
|
+
export async function getAggregatedPoolStatus(poolUrls) {
|
|
64
|
+
const results = await getAllPoolStatuses(poolUrls);
|
|
65
|
+
|
|
66
|
+
let totalRunning = 0;
|
|
67
|
+
let totalMaxConcurrent = 0;
|
|
68
|
+
let totalQueued = 0;
|
|
69
|
+
let availableCount = 0;
|
|
70
|
+
|
|
71
|
+
const pools = results.map(({ url, status, error }) => {
|
|
72
|
+
if (error || !status) {
|
|
73
|
+
return { url, available: false, error: error || 'unreachable', running: 0, maxConcurrent: 0, queued: 0, sessions: [] };
|
|
74
|
+
}
|
|
75
|
+
totalRunning += status.running;
|
|
76
|
+
totalMaxConcurrent += status.maxConcurrent;
|
|
77
|
+
totalQueued += status.queued;
|
|
78
|
+
if (status.available) availableCount++;
|
|
79
|
+
return { url, ...status };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
totalRunning,
|
|
84
|
+
totalMaxConcurrent,
|
|
85
|
+
totalQueued,
|
|
86
|
+
availableCount,
|
|
87
|
+
totalPools: poolUrls.length,
|
|
88
|
+
pools,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Blocks until at least one pool is reachable and available. */
|
|
93
|
+
export async function waitForAnyPool(poolUrls, maxWaitMs = 30000) {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
|
|
96
|
+
while (Date.now() - start < maxWaitMs) {
|
|
97
|
+
const results = await getAllPoolStatuses(poolUrls);
|
|
98
|
+
const available = results.find(r => r.status?.available);
|
|
99
|
+
if (available) return available.status;
|
|
100
|
+
|
|
101
|
+
const reachable = results.filter(r => r.status && !r.error);
|
|
102
|
+
if (reachable.length > 0) {
|
|
103
|
+
log('⏳', `${C.dim}Pool(s) busy (${reachable.length}/${poolUrls.length} reachable), waiting...${C.reset}`);
|
|
104
|
+
} else {
|
|
105
|
+
log('⏳', `${C.dim}No pools reachable yet (0/${poolUrls.length}), waiting...${C.reset}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await sleep(2000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw new Error(`No Chrome Pool available after ${maxWaitMs / 1000}s. Verify containers are running.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Picks the pool with the lowest pressure ratio.
|
|
116
|
+
*
|
|
117
|
+
* Algorithm:
|
|
118
|
+
* 1. Query all pools' /pressure in parallel
|
|
119
|
+
* 2. Add local pending count to each pool's running total
|
|
120
|
+
* 3. Filter to reachable pools with (running + pending) < maxConcurrent
|
|
121
|
+
* 4. Sort by: lowest effective pressure → fewest queued → most free slots
|
|
122
|
+
* 5. Track selection in pending counter, return best candidate URL
|
|
123
|
+
* 6. If all full, poll every 2s up to 60s, then pick least-pressured anyway
|
|
124
|
+
*/
|
|
125
|
+
export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000) {
|
|
126
|
+
// Fast path: single pool
|
|
127
|
+
if (poolUrls.length === 1) {
|
|
128
|
+
await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs);
|
|
129
|
+
trackPending(poolUrls[0]);
|
|
130
|
+
return poolUrls[0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
|
|
135
|
+
while (Date.now() - start < maxWaitMs) {
|
|
136
|
+
const results = await getAllPoolStatuses(poolUrls);
|
|
137
|
+
const candidates = results
|
|
138
|
+
.filter(r => r.status && !r.error && r.status.available)
|
|
139
|
+
.map(r => {
|
|
140
|
+
const pending = getPending(r.url);
|
|
141
|
+
const effectiveRunning = r.status.running + pending;
|
|
142
|
+
return {
|
|
143
|
+
url: r.url,
|
|
144
|
+
running: r.status.running,
|
|
145
|
+
pending,
|
|
146
|
+
effectiveRunning,
|
|
147
|
+
maxConcurrent: r.status.maxConcurrent,
|
|
148
|
+
queued: r.status.queued,
|
|
149
|
+
pressure: r.status.maxConcurrent > 0 ? effectiveRunning / r.status.maxConcurrent : 1,
|
|
150
|
+
freeSlots: r.status.maxConcurrent - effectiveRunning,
|
|
151
|
+
};
|
|
152
|
+
})
|
|
153
|
+
.filter(c => c.effectiveRunning < c.maxConcurrent);
|
|
154
|
+
|
|
155
|
+
if (candidates.length > 0) {
|
|
156
|
+
candidates.sort((a, b) => {
|
|
157
|
+
if (a.pressure !== b.pressure) return a.pressure - b.pressure;
|
|
158
|
+
if (a.queued !== b.queued) return a.queued - b.queued;
|
|
159
|
+
return b.freeSlots - a.freeSlots;
|
|
160
|
+
});
|
|
161
|
+
const chosen = candidates[0].url;
|
|
162
|
+
trackPending(chosen);
|
|
163
|
+
return chosen;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// All full — check if any are reachable
|
|
167
|
+
const reachable = results.filter(r => r.status && !r.error);
|
|
168
|
+
if (reachable.length > 0) {
|
|
169
|
+
log('⏳', `${C.dim}All pools at capacity (${reachable.length}/${poolUrls.length} reachable), waiting for slot...${C.reset}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await sleep(pollIntervalMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Timeout — pick the least-pressured pool anyway (let connectToPool deal with it)
|
|
176
|
+
const results = await getAllPoolStatuses(poolUrls);
|
|
177
|
+
const reachable = results
|
|
178
|
+
.filter(r => r.status && !r.error)
|
|
179
|
+
.sort((a, b) => {
|
|
180
|
+
const pendA = getPending(a.url);
|
|
181
|
+
const pendB = getPending(b.url);
|
|
182
|
+
const pA = a.status.maxConcurrent > 0 ? (a.status.running + pendA) / a.status.maxConcurrent : 1;
|
|
183
|
+
const pB = b.status.maxConcurrent > 0 ? (b.status.running + pendB) / b.status.maxConcurrent : 1;
|
|
184
|
+
return pA - pB;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (reachable.length > 0) {
|
|
188
|
+
log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding with least-pressured pool${C.reset}`);
|
|
189
|
+
const chosen = reachable[0].url;
|
|
190
|
+
trackPending(chosen);
|
|
191
|
+
return chosen;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// All unreachable — return first and let connectToPool error
|
|
195
|
+
return poolUrls[0];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Convenience: selectPool + connectToPool in one call. */
|
|
199
|
+
export async function selectAndConnect(config) {
|
|
200
|
+
const poolUrls = getPoolUrls(config);
|
|
201
|
+
const chosenUrl = await selectPool(poolUrls);
|
|
202
|
+
return connectToPool(chosenUrl, config.connectRetries, config.connectRetryDelay);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Waits until a single pool has capacity (replaces the old waitForSlot from runner.js). */
|
|
206
|
+
async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
|
|
207
|
+
const start = Date.now();
|
|
208
|
+
while (Date.now() - start < maxWaitMs) {
|
|
209
|
+
try {
|
|
210
|
+
const status = await getPoolStatus(poolUrl);
|
|
211
|
+
if (status.available && status.running < status.maxConcurrent) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
|
|
215
|
+
} catch {
|
|
216
|
+
// Pool unreachable, let connectToPool handle the error
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await sleep(pollIntervalMs);
|
|
220
|
+
}
|
|
221
|
+
// Timeout — proceed anyway and let connectToPool deal with it
|
|
222
|
+
log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
|
|
223
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -6,6 +6,11 @@ import fs from 'fs';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { colors as C } from './logger.js';
|
|
8
8
|
import { ensureProject, saveRun as saveRunToDb } from './db.js';
|
|
9
|
+
import { narrateTest } from './narrate.js';
|
|
10
|
+
import { learnFromRun } from './learner.js';
|
|
11
|
+
import { generateLearningsMarkdown } from './learner-markdown.js';
|
|
12
|
+
import { getHealthSnapshot, getRunInsights } from './learner-sqlite.js';
|
|
13
|
+
import { pushRun as syncPushRun } from './sync/client.js';
|
|
9
14
|
|
|
10
15
|
function escapeXml(str) {
|
|
11
16
|
return String(str)
|
|
@@ -147,17 +152,47 @@ export function loadHistoryRun(screenshotsDir, runId) {
|
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
/** Persists a run to both filesystem history and SQLite (never throws). */
|
|
150
|
-
export function persistRun(report, config, suiteName) {
|
|
155
|
+
export async function persistRun(report, config, suiteName) {
|
|
151
156
|
const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
|
|
157
|
+
let runDbId = null;
|
|
152
158
|
|
|
153
159
|
try {
|
|
154
160
|
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
155
|
-
saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
|
|
161
|
+
runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
|
|
162
|
+
|
|
163
|
+
// Fire-and-forget: learn from this run (never blocks or crashes the runner)
|
|
164
|
+
if (config.learningsEnabled !== false) {
|
|
165
|
+
try {
|
|
166
|
+
learnFromRun(projectId, runDbId, report, config, suiteName);
|
|
167
|
+
} catch (learnErr) {
|
|
168
|
+
process.stderr.write(`[e2e-runner] Learning write failed: ${learnErr.message}\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Generate learnings markdown if enabled
|
|
172
|
+
if (config.learningsMarkdown !== false) {
|
|
173
|
+
try {
|
|
174
|
+
generateLearningsMarkdown(projectId, config);
|
|
175
|
+
} catch (mdErr) {
|
|
176
|
+
process.stderr.write(`[e2e-runner] Learnings markdown failed: ${mdErr.message}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Sync push if in agent mode with autoSync enabled
|
|
182
|
+
if (config.sync?.mode === 'agent' && config.sync?.agent?.autoSync !== false) {
|
|
183
|
+
try {
|
|
184
|
+
const project = { name: config.projectName, slug: config.projectName.toLowerCase().replace(/[^a-z0-9]+/g, '-') };
|
|
185
|
+
const enrichedReport = { ...report, runId, suiteName, triggeredBy: config.triggeredBy };
|
|
186
|
+
await syncPushRun(config, project, enrichedReport);
|
|
187
|
+
} catch (syncErr) {
|
|
188
|
+
process.stderr.write(`[e2e-runner] Sync push failed: ${syncErr.message}\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
156
191
|
} catch (err) {
|
|
157
192
|
process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
|
|
158
193
|
}
|
|
159
194
|
|
|
160
|
-
return runId;
|
|
195
|
+
return { runId, runDbId };
|
|
161
196
|
}
|
|
162
197
|
|
|
163
198
|
/** Prints a formatted report summary to the console */
|
|
@@ -222,8 +257,87 @@ export function printReport(report, screenshotsDir) {
|
|
|
222
257
|
});
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
// Print step-by-step narrative for each test
|
|
261
|
+
console.log(`\n${C.bold}NARRATIVE:${C.reset}`);
|
|
262
|
+
for (const result of report.results) {
|
|
263
|
+
const icon = result.success ? `${C.green}✓${C.reset}` : `${C.red}✗${C.reset}`;
|
|
264
|
+
console.log(` ${icon} ${C.bold}${result.name}${C.reset}`);
|
|
265
|
+
const steps = narrateTest(result);
|
|
266
|
+
for (const step of steps) {
|
|
267
|
+
console.log(` ${C.dim}${step}${C.reset}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
225
271
|
if (screenshotsDir) {
|
|
226
272
|
console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
|
|
227
273
|
console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
|
|
228
274
|
}
|
|
229
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Prints a compact learnings/health block after the run report. Never throws. */
|
|
278
|
+
export function printInsights(report, config) {
|
|
279
|
+
try {
|
|
280
|
+
if (config.learningsEnabled === false) return;
|
|
281
|
+
|
|
282
|
+
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
283
|
+
const health = getHealthSnapshot(projectId);
|
|
284
|
+
const insights = getRunInsights(projectId, report);
|
|
285
|
+
|
|
286
|
+
// Nothing to show if no historical data and no insights
|
|
287
|
+
if (!health && insights.length === 0) return;
|
|
288
|
+
|
|
289
|
+
const lines = [];
|
|
290
|
+
const LINE = `${C.dim}${'─'.repeat(42)}${C.reset}`;
|
|
291
|
+
|
|
292
|
+
// Run-specific insights
|
|
293
|
+
const newFailures = insights.filter(i => i.type === 'new-failure');
|
|
294
|
+
const flaky = insights.filter(i => i.type === 'flaky');
|
|
295
|
+
const recovered = insights.filter(i => i.type === 'recovered');
|
|
296
|
+
const unstable = insights.find(i => i.type === 'unstable-selectors');
|
|
297
|
+
|
|
298
|
+
if (newFailures.length > 0) {
|
|
299
|
+
lines.push(` ${C.red}!${C.reset} ${newFailures.length} new failure(s) (previously stable)`);
|
|
300
|
+
for (const f of newFailures.slice(0, 3)) {
|
|
301
|
+
lines.push(` ${C.dim}- ${f.test}${C.reset}`);
|
|
302
|
+
}
|
|
303
|
+
if (newFailures.length > 3) lines.push(` ${C.dim}... and ${newFailures.length - 3} more${C.reset}`);
|
|
304
|
+
}
|
|
305
|
+
if (recovered.length > 0) {
|
|
306
|
+
lines.push(` ${C.green}+${C.reset} ${recovered.length} recovered test(s)`);
|
|
307
|
+
}
|
|
308
|
+
if (flaky.length > 0) {
|
|
309
|
+
lines.push(` ${C.yellow}~${C.reset} ${flaky.length} known flaky test(s) passed this time`);
|
|
310
|
+
}
|
|
311
|
+
if (unstable) {
|
|
312
|
+
const sels = unstable.selectors.slice(0, 3).join(', ');
|
|
313
|
+
lines.push(` ${C.red}!${C.reset} ${unstable.selectors.length} unstable selector(s): ${C.dim}${sels}${C.reset}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Health snapshot
|
|
317
|
+
if (health) {
|
|
318
|
+
const rateColor = health.passRate >= 90 ? C.green : health.passRate >= 70 ? C.yellow : C.red;
|
|
319
|
+
const trendIcon = health.passRateTrend === 'improving' ? `${C.green}^${C.reset}` : health.passRateTrend === 'declining' ? `${C.red}v${C.reset}` : `${C.dim}=${C.reset}`;
|
|
320
|
+
const deltaStr = health.trendDelta !== 0 ? `, ${health.trendDelta > 0 ? '+' : ''}${health.trendDelta}%` : '';
|
|
321
|
+
lines.push(` ${trendIcon} Pass rate: ${rateColor}${health.passRate}%${C.reset} (${health.passRateTrend}${deltaStr})`);
|
|
322
|
+
|
|
323
|
+
if (health.topErrorPattern) {
|
|
324
|
+
const cat = health.topErrorPattern.category || health.topErrorPattern.pattern || 'unknown';
|
|
325
|
+
const label = cat.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
326
|
+
lines.push(` ${C.dim}!${C.reset} Top error: ${C.dim}${label} (${health.topErrorPattern.count}x)${C.reset}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (lines.length === 0) return;
|
|
331
|
+
|
|
332
|
+
console.log('');
|
|
333
|
+
console.log(`${C.dim}── ${C.reset}${C.bold}Learnings${C.reset} ${LINE}`);
|
|
334
|
+
for (const line of lines) console.log(line);
|
|
335
|
+
console.log(` ${C.dim}Run 'e2e-runner learnings' for full details${C.reset}`);
|
|
336
|
+
if (config.learningsMarkdown !== false) {
|
|
337
|
+
console.log(` ${C.dim}Updated: e2e/learnings.md${C.reset}`);
|
|
338
|
+
}
|
|
339
|
+
console.log(LINE);
|
|
340
|
+
} catch {
|
|
341
|
+
// Never fail the run
|
|
342
|
+
}
|
|
343
|
+
}
|