@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
package/src/db.js
CHANGED
|
@@ -107,6 +107,13 @@ function migrate(db) {
|
|
|
107
107
|
db.exec('ALTER TABLE test_results ADD COLUMN network_logs TEXT');
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Add actions_json column if upgrading from older schema
|
|
111
|
+
try {
|
|
112
|
+
db.prepare('SELECT actions_json FROM test_results LIMIT 0').run();
|
|
113
|
+
} catch {
|
|
114
|
+
db.exec('ALTER TABLE test_results ADD COLUMN actions_json TEXT');
|
|
115
|
+
}
|
|
116
|
+
|
|
110
117
|
// Add triggered_by column if upgrading from older schema
|
|
111
118
|
try {
|
|
112
119
|
db.prepare('SELECT triggered_by FROM runs LIMIT 0').run();
|
|
@@ -125,6 +132,137 @@ function migrate(db) {
|
|
|
125
132
|
);
|
|
126
133
|
CREATE INDEX IF NOT EXISTS idx_ss_path ON screenshot_hashes(file_path);
|
|
127
134
|
`);
|
|
135
|
+
|
|
136
|
+
// ── Learning system tables ──────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
db.exec(`
|
|
139
|
+
CREATE TABLE IF NOT EXISTS test_learnings (
|
|
140
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
141
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
142
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
143
|
+
test_name TEXT NOT NULL,
|
|
144
|
+
success INTEGER NOT NULL,
|
|
145
|
+
duration_ms INTEGER,
|
|
146
|
+
flaky INTEGER DEFAULT 0,
|
|
147
|
+
attempt INTEGER DEFAULT 1,
|
|
148
|
+
max_attempts INTEGER DEFAULT 1,
|
|
149
|
+
error_pattern TEXT,
|
|
150
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
151
|
+
);
|
|
152
|
+
CREATE INDEX IF NOT EXISTS idx_tl_project ON test_learnings(project_id);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_tl_test ON test_learnings(test_name);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_tl_created ON test_learnings(created_at);
|
|
155
|
+
|
|
156
|
+
CREATE TABLE IF NOT EXISTS selector_learnings (
|
|
157
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
158
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
159
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
160
|
+
selector TEXT NOT NULL,
|
|
161
|
+
action_type TEXT NOT NULL,
|
|
162
|
+
success INTEGER NOT NULL,
|
|
163
|
+
page_url TEXT,
|
|
164
|
+
test_name TEXT,
|
|
165
|
+
error TEXT,
|
|
166
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
167
|
+
);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_sl_project ON selector_learnings(project_id);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_sl_selector ON selector_learnings(selector);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS page_learnings (
|
|
172
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
173
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
174
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
175
|
+
url_path TEXT NOT NULL,
|
|
176
|
+
load_time_ms INTEGER,
|
|
177
|
+
console_errors INTEGER DEFAULT 0,
|
|
178
|
+
console_warns INTEGER DEFAULT 0,
|
|
179
|
+
network_errors INTEGER DEFAULT 0,
|
|
180
|
+
test_name TEXT,
|
|
181
|
+
success INTEGER NOT NULL,
|
|
182
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
183
|
+
);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_pl_project ON page_learnings(project_id);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_pl_url ON page_learnings(url_path);
|
|
186
|
+
|
|
187
|
+
CREATE TABLE IF NOT EXISTS api_learnings (
|
|
188
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
190
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
191
|
+
endpoint TEXT NOT NULL,
|
|
192
|
+
method TEXT NOT NULL,
|
|
193
|
+
status INTEGER,
|
|
194
|
+
duration_ms INTEGER,
|
|
195
|
+
is_error INTEGER DEFAULT 0,
|
|
196
|
+
test_name TEXT,
|
|
197
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
198
|
+
);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS error_patterns (
|
|
203
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
205
|
+
pattern TEXT NOT NULL,
|
|
206
|
+
category TEXT NOT NULL,
|
|
207
|
+
occurrence_count INTEGER DEFAULT 1,
|
|
208
|
+
first_seen TEXT DEFAULT (datetime('now')),
|
|
209
|
+
last_seen TEXT DEFAULT (datetime('now')),
|
|
210
|
+
example_error TEXT,
|
|
211
|
+
example_test TEXT,
|
|
212
|
+
UNIQUE(project_id, pattern)
|
|
213
|
+
);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_ep_project ON error_patterns(project_id);
|
|
215
|
+
CREATE INDEX IF NOT EXISTS idx_ep_cat ON error_patterns(category);
|
|
216
|
+
|
|
217
|
+
CREATE TABLE IF NOT EXISTS learning_summary (
|
|
218
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
219
|
+
project_id INTEGER NOT NULL UNIQUE REFERENCES projects(id),
|
|
220
|
+
total_runs INTEGER DEFAULT 0,
|
|
221
|
+
total_tests INTEGER DEFAULT 0,
|
|
222
|
+
overall_pass_rate REAL DEFAULT 0,
|
|
223
|
+
avg_duration_ms REAL DEFAULT 0,
|
|
224
|
+
flaky_tests TEXT,
|
|
225
|
+
slow_tests TEXT,
|
|
226
|
+
unstable_selectors TEXT,
|
|
227
|
+
failing_pages TEXT,
|
|
228
|
+
api_issues TEXT,
|
|
229
|
+
top_errors TEXT,
|
|
230
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
231
|
+
);
|
|
232
|
+
`);
|
|
233
|
+
|
|
234
|
+
// ── Variables table ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
db.exec(`
|
|
237
|
+
CREATE TABLE IF NOT EXISTS variables (
|
|
238
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
239
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
240
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
241
|
+
key TEXT NOT NULL,
|
|
242
|
+
value TEXT NOT NULL,
|
|
243
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
244
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
245
|
+
UNIQUE(project_id, scope, key)
|
|
246
|
+
);
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_vars_project ON variables(project_id);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_vars_scope ON variables(project_id, scope);
|
|
249
|
+
`);
|
|
250
|
+
|
|
251
|
+
// Add pool_url column for multi-pool tracking
|
|
252
|
+
try {
|
|
253
|
+
db.prepare('SELECT pool_url FROM test_results LIMIT 0').run();
|
|
254
|
+
} catch {
|
|
255
|
+
db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Migrations: add metadata columns to screenshot_hashes
|
|
259
|
+
const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
|
|
260
|
+
if (!ssColumns.includes('test_name')) {
|
|
261
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN test_name TEXT');
|
|
262
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN step_index INTEGER');
|
|
263
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN page_url TEXT');
|
|
264
|
+
db.exec('ALTER TABLE screenshot_hashes ADD COLUMN screenshot_type TEXT');
|
|
265
|
+
}
|
|
128
266
|
}
|
|
129
267
|
|
|
130
268
|
/** Upsert a project row. Returns the project id. */
|
|
@@ -169,17 +307,19 @@ export function computeScreenshotHash(filePath) {
|
|
|
169
307
|
return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 8);
|
|
170
308
|
}
|
|
171
309
|
|
|
172
|
-
/** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. */
|
|
173
|
-
export function registerScreenshotHash(hash, filePath, projectId, runDbId) {
|
|
310
|
+
/** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. Optional metadata: testName, stepIndex, pageUrl, screenshotType. */
|
|
311
|
+
export function registerScreenshotHash(hash, filePath, projectId, runDbId, meta = {}) {
|
|
174
312
|
const d = getDb();
|
|
175
|
-
d.prepare(
|
|
313
|
+
d.prepare(
|
|
314
|
+
'INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id, test_name, step_index, page_url, screenshot_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
315
|
+
).run(hash, filePath, projectId || null, runDbId || null, meta.testName || null, meta.stepIndex ?? null, meta.pageUrl || null, meta.screenshotType || null);
|
|
176
316
|
}
|
|
177
317
|
|
|
178
|
-
/** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id } or null. */
|
|
318
|
+
/** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id, test_name, step_index, page_url, screenshot_type } or null. */
|
|
179
319
|
export function lookupScreenshotHash(rawHash) {
|
|
180
320
|
const d = getDb();
|
|
181
321
|
const hash = rawHash.replace(/^ss:/, '');
|
|
182
|
-
return d.prepare('SELECT hash, file_path, project_id FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
|
|
322
|
+
return d.prepare('SELECT hash, file_path, project_id, test_name, step_index, page_url, screenshot_type FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
|
|
183
323
|
}
|
|
184
324
|
|
|
185
325
|
/** Batch lookup: given an array of file paths, returns { [path]: hash } map. */
|
|
@@ -206,11 +346,11 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
206
346
|
`);
|
|
207
347
|
|
|
208
348
|
const insertTest = d.prepare(`
|
|
209
|
-
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)
|
|
210
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
211
351
|
`);
|
|
212
352
|
|
|
213
|
-
const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)');
|
|
353
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
214
354
|
|
|
215
355
|
const tx = d.transaction(() => {
|
|
216
356
|
const runInfo = insertRun.run(
|
|
@@ -237,6 +377,19 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
237
377
|
.filter(a => a.type === 'screenshot' && a.result?.screenshot)
|
|
238
378
|
.map(a => a.result.screenshot);
|
|
239
379
|
|
|
380
|
+
// Condensed actions for narrative display
|
|
381
|
+
const actionsCondensed = (r.actions || []).map(a => ({
|
|
382
|
+
type: a.type,
|
|
383
|
+
selector: a.selector || undefined,
|
|
384
|
+
value: a.value || undefined,
|
|
385
|
+
text: a.text || undefined,
|
|
386
|
+
success: a.success,
|
|
387
|
+
duration: a.duration,
|
|
388
|
+
narrative: a.narrative || undefined,
|
|
389
|
+
error: a.error || undefined,
|
|
390
|
+
actionRetries: a.actionRetries || undefined,
|
|
391
|
+
}));
|
|
392
|
+
|
|
240
393
|
insertTest.run(
|
|
241
394
|
runDbId,
|
|
242
395
|
r.name,
|
|
@@ -252,17 +405,25 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
252
405
|
r.networkErrors ? JSON.stringify(r.networkErrors) : null,
|
|
253
406
|
screenshots.length ? JSON.stringify(screenshots) : null,
|
|
254
407
|
r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
|
|
408
|
+
actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
|
|
409
|
+
r.poolUrl || null,
|
|
255
410
|
);
|
|
256
411
|
|
|
257
|
-
// Register screenshot hashes
|
|
258
|
-
|
|
259
|
-
|
|
412
|
+
// Register screenshot hashes with metadata
|
|
413
|
+
const ssActions = (r.actions || []).filter(a => a.type === 'screenshot' && a.result?.screenshot);
|
|
414
|
+
for (let si = 0; si < ssActions.length; si++) {
|
|
415
|
+
const a = ssActions[si];
|
|
416
|
+
const actionIdx = r.actions.indexOf(a);
|
|
417
|
+
insertHash.run(computeScreenshotHash(a.result.screenshot), a.result.screenshot, projectId, runDbId, r.name, actionIdx, null, 'action');
|
|
260
418
|
}
|
|
261
419
|
if (r.errorScreenshot) {
|
|
262
|
-
insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId);
|
|
420
|
+
insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId, r.name, null, null, 'error');
|
|
263
421
|
}
|
|
264
422
|
if (r.verificationScreenshot) {
|
|
265
|
-
insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId);
|
|
423
|
+
insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
|
|
424
|
+
}
|
|
425
|
+
if (r.baselineScreenshot) {
|
|
426
|
+
insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
|
|
266
427
|
}
|
|
267
428
|
}
|
|
268
429
|
|
|
@@ -272,6 +433,33 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
272
433
|
return tx();
|
|
273
434
|
}
|
|
274
435
|
|
|
436
|
+
/** Save a run from sync (remote instance). Returns the run's DB id. */
|
|
437
|
+
export function persistRunFromSync({ projectId, runId, total, passed, failed, passRate, duration, generatedAt, suiteName, triggeredBy, syncInstanceId, syncOrigin }) {
|
|
438
|
+
const d = getDb();
|
|
439
|
+
|
|
440
|
+
const stmt = d.prepare(`
|
|
441
|
+
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, sync_instance_id, sync_origin, synced_at)
|
|
442
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
443
|
+
`);
|
|
444
|
+
|
|
445
|
+
const result = stmt.run(
|
|
446
|
+
projectId,
|
|
447
|
+
runId,
|
|
448
|
+
total,
|
|
449
|
+
passed,
|
|
450
|
+
failed,
|
|
451
|
+
passRate,
|
|
452
|
+
duration,
|
|
453
|
+
generatedAt,
|
|
454
|
+
suiteName || null,
|
|
455
|
+
triggeredBy || null,
|
|
456
|
+
syncInstanceId || null,
|
|
457
|
+
syncOrigin || 'remote'
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
return result.lastInsertRowid;
|
|
461
|
+
}
|
|
462
|
+
|
|
275
463
|
/** List all projects with aggregated stats. */
|
|
276
464
|
export function listProjects() {
|
|
277
465
|
const d = getDb();
|
|
@@ -352,7 +540,9 @@ export function getRunDetail(runDbId) {
|
|
|
352
540
|
consoleLogs: t.console_logs ? JSON.parse(t.console_logs) : [],
|
|
353
541
|
networkErrors: t.network_errors ? JSON.parse(t.network_errors) : [],
|
|
354
542
|
networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
|
|
543
|
+
actions: t.actions_json ? JSON.parse(t.actions_json) : [],
|
|
355
544
|
screenshotHashes,
|
|
545
|
+
poolUrl: t.pool_url || null,
|
|
356
546
|
};
|
|
357
547
|
}),
|
|
358
548
|
};
|
|
@@ -378,7 +568,139 @@ export function getRunCount() {
|
|
|
378
568
|
return row.cnt;
|
|
379
569
|
}
|
|
380
570
|
|
|
571
|
+
/** Query network logs for a run with optional filters.
|
|
572
|
+
* Filters: testName, method, statusMin, statusMax, urlPattern, errorsOnly, includeHeaders, includeBodies.
|
|
573
|
+
* By default returns only: url, method, status, statusText, duration.
|
|
574
|
+
*/
|
|
575
|
+
export function getNetworkLogs(runDbId, filters = {}) {
|
|
576
|
+
const d = getDb();
|
|
577
|
+
|
|
578
|
+
let query = 'SELECT name, network_logs FROM test_results WHERE run_id = ?';
|
|
579
|
+
const params = [runDbId];
|
|
580
|
+
|
|
581
|
+
if (filters.testName) {
|
|
582
|
+
query += ' AND name = ?';
|
|
583
|
+
params.push(filters.testName);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const rows = d.prepare(query).all(...params);
|
|
587
|
+
const results = [];
|
|
588
|
+
|
|
589
|
+
for (const row of rows) {
|
|
590
|
+
if (!row.network_logs) continue;
|
|
591
|
+
let logs = JSON.parse(row.network_logs);
|
|
592
|
+
|
|
593
|
+
if (filters.method) {
|
|
594
|
+
logs = logs.filter(l => l.method === filters.method.toUpperCase());
|
|
595
|
+
}
|
|
596
|
+
if (filters.statusMin !== undefined) {
|
|
597
|
+
logs = logs.filter(l => l.status >= filters.statusMin);
|
|
598
|
+
}
|
|
599
|
+
if (filters.statusMax !== undefined) {
|
|
600
|
+
logs = logs.filter(l => l.status <= filters.statusMax);
|
|
601
|
+
}
|
|
602
|
+
if (filters.urlPattern) {
|
|
603
|
+
const re = new RegExp(filters.urlPattern, 'i');
|
|
604
|
+
logs = logs.filter(l => re.test(l.url));
|
|
605
|
+
}
|
|
606
|
+
if (filters.errorsOnly) {
|
|
607
|
+
logs = logs.filter(l => l.status >= 400);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const mapped = logs.map(l => {
|
|
611
|
+
const entry = { url: l.url, method: l.method, status: l.status, statusText: l.statusText, duration: l.duration };
|
|
612
|
+
if (filters.includeHeaders || filters.includeBodies) {
|
|
613
|
+
entry.requestHeaders = l.requestHeaders;
|
|
614
|
+
entry.responseHeaders = l.responseHeaders;
|
|
615
|
+
}
|
|
616
|
+
if (filters.includeBodies) {
|
|
617
|
+
entry.requestBody = l.requestBody;
|
|
618
|
+
entry.responseBody = l.responseBody;
|
|
619
|
+
}
|
|
620
|
+
return entry;
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (mapped.length > 0) {
|
|
624
|
+
results.push({ testName: row.name, logs: mapped });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return results;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── Variables CRUD ────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
/** Upsert a variable. Scope is 'project' or a suite name. */
|
|
634
|
+
export function setVariable(projectId, scope, key, value) {
|
|
635
|
+
const d = getDb();
|
|
636
|
+
d.prepare(`
|
|
637
|
+
INSERT INTO variables (project_id, scope, key, value, updated_at)
|
|
638
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
639
|
+
ON CONFLICT(project_id, scope, key)
|
|
640
|
+
DO UPDATE SET value = excluded.value, updated_at = datetime('now')
|
|
641
|
+
`).run(projectId, scope, key, value);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Get variables for a specific scope. Returns { key: value } map. */
|
|
645
|
+
export function getVariables(projectId, scope) {
|
|
646
|
+
const d = getDb();
|
|
647
|
+
const rows = d.prepare('SELECT key, value FROM variables WHERE project_id = ? AND scope = ?').all(projectId, scope);
|
|
648
|
+
const map = {};
|
|
649
|
+
for (const r of rows) map[r.key] = r.value;
|
|
650
|
+
return map;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Delete a variable. Returns true if deleted. */
|
|
654
|
+
export function deleteVariable(projectId, scope, key) {
|
|
655
|
+
const d = getDb();
|
|
656
|
+
const info = d.prepare('DELETE FROM variables WHERE project_id = ? AND scope = ? AND key = ?').run(projectId, scope, key);
|
|
657
|
+
return info.changes > 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** List all variables for a project, grouped by scope. Returns { scope: { key: value } }. */
|
|
661
|
+
export function listVariables(projectId) {
|
|
662
|
+
const d = getDb();
|
|
663
|
+
const rows = d.prepare('SELECT scope, key, value FROM variables WHERE project_id = ? ORDER BY scope, key').all(projectId);
|
|
664
|
+
const grouped = {};
|
|
665
|
+
for (const r of rows) {
|
|
666
|
+
if (!grouped[r.scope]) grouped[r.scope] = {};
|
|
667
|
+
grouped[r.scope][r.key] = r.value;
|
|
668
|
+
}
|
|
669
|
+
return grouped;
|
|
670
|
+
}
|
|
671
|
+
|
|
381
672
|
/** Close the database connection. */
|
|
673
|
+
/** Projects with sparkline data (last N run pass rates, oldest→newest). */
|
|
674
|
+
export function listProjectsWithSparklines(sparklineSize = 20) {
|
|
675
|
+
const d = getDb();
|
|
676
|
+
const projects = d.prepare(`
|
|
677
|
+
SELECT
|
|
678
|
+
p.id, p.cwd, p.name, p.screenshots_dir, p.tests_dir, p.created_at, p.updated_at,
|
|
679
|
+
COUNT(r.id) AS runCount,
|
|
680
|
+
MAX(r.generated_at) AS lastRunAt,
|
|
681
|
+
(SELECT r2.pass_rate FROM runs r2 WHERE r2.project_id = p.id ORDER BY r2.generated_at DESC LIMIT 1) AS lastPassRate
|
|
682
|
+
FROM projects p
|
|
683
|
+
LEFT JOIN runs r ON r.project_id = p.id
|
|
684
|
+
GROUP BY p.id
|
|
685
|
+
ORDER BY p.updated_at DESC
|
|
686
|
+
`).all();
|
|
687
|
+
|
|
688
|
+
const sparkStmt = d.prepare(`
|
|
689
|
+
SELECT CAST(pass_rate AS REAL) AS rate
|
|
690
|
+
FROM runs
|
|
691
|
+
WHERE project_id = ?
|
|
692
|
+
ORDER BY generated_at DESC
|
|
693
|
+
LIMIT ?
|
|
694
|
+
`);
|
|
695
|
+
|
|
696
|
+
return projects.map(p => {
|
|
697
|
+
const rawRates = sparkStmt.all(p.id, sparklineSize).map(r => r.rate);
|
|
698
|
+
// Reverse to oldest→newest
|
|
699
|
+
rawRates.reverse();
|
|
700
|
+
return { ...p, sparkline: rawRates };
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
382
704
|
export function closeDb() {
|
|
383
705
|
if (db) {
|
|
384
706
|
db.close();
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export { loadConfig } from './config.js';
|
|
11
11
|
export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
|
|
12
|
+
export { getPoolUrls, getAllPoolStatuses, getAggregatedPoolStatus, waitForAnyPool, selectPool, selectAndConnect } from './pool-manager.js';
|
|
12
13
|
export { executeAction } from './actions.js';
|
|
13
14
|
export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
|
|
14
15
|
export { generateReport, generateJUnitXML, saveReport, printReport, saveHistory, loadHistory, loadHistoryRun } from './reporter.js';
|
|
@@ -16,9 +17,15 @@ export { startDashboard, stopDashboard } from './dashboard.js';
|
|
|
16
17
|
export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
|
|
17
18
|
export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
|
|
18
19
|
export { verifyIssue } from './verify.js';
|
|
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';
|
|
23
|
+
export { generateLearningsMarkdown } from './learner-markdown.js';
|
|
24
|
+
export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
|
|
25
|
+
export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
|
|
19
26
|
|
|
20
27
|
import { loadConfig } from './config.js';
|
|
21
|
-
import {
|
|
28
|
+
import { waitForAnyPool, getPoolUrls } from './pool-manager.js';
|
|
22
29
|
import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites } from './runner.js';
|
|
23
30
|
import { generateReport, saveReport, printReport } from './reporter.js';
|
|
24
31
|
|
|
@@ -35,8 +42,8 @@ export async function createRunner(userConfig = {}) {
|
|
|
35
42
|
|
|
36
43
|
/** Runs all test suites from the tests directory */
|
|
37
44
|
async runAll() {
|
|
38
|
-
await
|
|
39
|
-
const { tests, hooks } = loadAllSuites(config.testsDir);
|
|
45
|
+
await waitForAnyPool(getPoolUrls(config));
|
|
46
|
+
const { tests, hooks } = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
|
|
40
47
|
const results = await runTestsParallel(tests, config, hooks);
|
|
41
48
|
const report = generateReport(results);
|
|
42
49
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -46,8 +53,8 @@ export async function createRunner(userConfig = {}) {
|
|
|
46
53
|
|
|
47
54
|
/** Runs a single suite by name */
|
|
48
55
|
async runSuite(name) {
|
|
49
|
-
await
|
|
50
|
-
const { tests, hooks } = loadTestSuite(name, config.testsDir);
|
|
56
|
+
await waitForAnyPool(getPoolUrls(config));
|
|
57
|
+
const { tests, hooks } = loadTestSuite(name, config.testsDir, config.modulesDir);
|
|
51
58
|
const results = await runTestsParallel(tests, config, hooks);
|
|
52
59
|
const report = generateReport(results);
|
|
53
60
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -57,7 +64,7 @@ export async function createRunner(userConfig = {}) {
|
|
|
57
64
|
|
|
58
65
|
/** Runs an array of test objects */
|
|
59
66
|
async runTests(tests) {
|
|
60
|
-
await
|
|
67
|
+
await waitForAnyPool(getPoolUrls(config));
|
|
61
68
|
const results = await runTestsParallel(tests, config);
|
|
62
69
|
const report = generateReport(results);
|
|
63
70
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -67,8 +74,8 @@ export async function createRunner(userConfig = {}) {
|
|
|
67
74
|
|
|
68
75
|
/** Runs tests from a JSON file path */
|
|
69
76
|
async runFile(filePath) {
|
|
70
|
-
await
|
|
71
|
-
const { tests, hooks } = loadTestFile(filePath);
|
|
77
|
+
await waitForAnyPool(getPoolUrls(config));
|
|
78
|
+
const { tests, hooks } = loadTestFile(filePath, config.modulesDir);
|
|
72
79
|
const results = await runTestsParallel(tests, config, hooks);
|
|
73
80
|
const report = generateReport(results);
|
|
74
81
|
saveReport(report, config.screenshotsDir, config);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learnings markdown generator.
|
|
3
|
+
*
|
|
4
|
+
* Generates {cwd}/e2e/learnings.md after each run, reading from SQLite.
|
|
5
|
+
* The file is designed to be portable, versionable in git, and human-readable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
getLearningsSummary,
|
|
12
|
+
getFlakySummary,
|
|
13
|
+
getSelectorStability,
|
|
14
|
+
getPageHealth,
|
|
15
|
+
getApiHealth,
|
|
16
|
+
getErrorPatterns,
|
|
17
|
+
getTestTrends,
|
|
18
|
+
} from './learner-sqlite.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generates the learnings.md file for a project.
|
|
22
|
+
* Reads from SQLite and writes to {cwd}/e2e/learnings.md.
|
|
23
|
+
*/
|
|
24
|
+
export function generateLearningsMarkdown(projectId, config) {
|
|
25
|
+
const days = config?.learningsDays || 30;
|
|
26
|
+
const summary = getLearningsSummary(projectId);
|
|
27
|
+
const flaky = getFlakySummary(projectId, days);
|
|
28
|
+
const selectors = getSelectorStability(projectId, days);
|
|
29
|
+
const pages = getPageHealth(projectId, days);
|
|
30
|
+
const apis = getApiHealth(projectId, days);
|
|
31
|
+
const errors = getErrorPatterns(projectId);
|
|
32
|
+
const trendsResult = getTestTrends(projectId, 7);
|
|
33
|
+
const trends = trendsResult.data || trendsResult;
|
|
34
|
+
const trendsGranularity = trendsResult.granularity || 'daily';
|
|
35
|
+
|
|
36
|
+
const lines = [];
|
|
37
|
+
|
|
38
|
+
lines.push('# E2E Test Learnings');
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push(`> Auto-generated after each test run. Analysis window: **${days} days**.`);
|
|
41
|
+
lines.push(`> Last updated: ${summary.updatedAt || 'never'}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
|
|
44
|
+
// ── Health Overview ─────────────────────────────────────────────────────────
|
|
45
|
+
lines.push('## Health Overview');
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('| Metric | Value |');
|
|
48
|
+
lines.push('|--------|-------|');
|
|
49
|
+
lines.push(`| Total Runs | ${summary.totalRuns} |`);
|
|
50
|
+
lines.push(`| Total Tests | ${summary.totalTests} |`);
|
|
51
|
+
lines.push(`| Pass Rate | ${summary.overallPassRate}% |`);
|
|
52
|
+
lines.push(`| Avg Duration | ${formatDuration(summary.avgDurationMs)} |`);
|
|
53
|
+
lines.push(`| Flaky Tests | ${flaky.length} |`);
|
|
54
|
+
lines.push(`| Unstable Selectors | ${selectors.length} |`);
|
|
55
|
+
|
|
56
|
+
// Trend arrow (compare last 2 days)
|
|
57
|
+
if (trends.length >= 2) {
|
|
58
|
+
const latest = trends[trends.length - 1];
|
|
59
|
+
const prev = trends[trends.length - 2];
|
|
60
|
+
const diff = latest.pass_rate - prev.pass_rate;
|
|
61
|
+
const arrow = diff > 0 ? 'improving' : diff < 0 ? 'declining' : 'stable';
|
|
62
|
+
lines.push(`| 7-Day Trend | ${arrow} (${diff > 0 ? '+' : ''}${diff.toFixed(1)}%) |`);
|
|
63
|
+
}
|
|
64
|
+
lines.push('');
|
|
65
|
+
|
|
66
|
+
// ── Flaky Tests ─────────────────────────────────────────────────────────────
|
|
67
|
+
if (flaky.length > 0) {
|
|
68
|
+
lines.push('## Flaky Tests');
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push('Tests that pass only after retries — potential stability issues.');
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push('| Test | Flaky Rate | Occurrences | Total Runs | Last Flaky | Avg Attempts |');
|
|
73
|
+
lines.push('|------|-----------|-------------|------------|------------|-------------|');
|
|
74
|
+
for (const f of flaky) {
|
|
75
|
+
lines.push(`| ${f.test_name} | ${f.flaky_rate}% | ${f.flaky_count} | ${f.total_runs} | ${formatDate(f.last_flaky)} | ${f.avg_attempts} |`);
|
|
76
|
+
}
|
|
77
|
+
lines.push('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Unstable Selectors ──────────────────────────────────────────────────────
|
|
81
|
+
if (selectors.length > 0) {
|
|
82
|
+
lines.push('## Unstable Selectors');
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push('CSS selectors that fail intermittently — candidates for improvement.');
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push('| Selector | Action | Fail Rate | Uses | Tests | Page | Error |');
|
|
87
|
+
lines.push('|----------|--------|-----------|------|-------|------|-------|');
|
|
88
|
+
for (const s of selectors.slice(0, 20)) {
|
|
89
|
+
const selector = truncate(s.selector, 40);
|
|
90
|
+
const error = truncate(s.last_error || '-', 30);
|
|
91
|
+
lines.push(`| \`${selector}\` | ${s.action_type} | ${s.fail_rate}% | ${s.total_uses} | ${s.used_by_tests} | ${s.page_url || '-'} | ${error} |`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Failing Pages ───────────────────────────────────────────────────────────
|
|
97
|
+
const failingPages = pages.filter(p => p.fail_rate > 0);
|
|
98
|
+
if (failingPages.length > 0) {
|
|
99
|
+
lines.push('## Failing Pages');
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('| Page | Fail Rate | Visits | Tests | Console Errors | Network Errors | Avg Load |');
|
|
102
|
+
lines.push('|------|-----------|--------|-------|---------------|----------------|----------|');
|
|
103
|
+
for (const p of failingPages.slice(0, 20)) {
|
|
104
|
+
lines.push(`| ${p.url_path} | ${p.fail_rate}% | ${p.total_visits} | ${p.tested_by} | ${p.console_errors} | ${p.network_errors} | ${formatDuration(p.avg_load_ms)} |`);
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── API Issues ──────────────────────────────────────────────────────────────
|
|
110
|
+
const apiIssues = apis.filter(a => a.error_rate > 0);
|
|
111
|
+
if (apiIssues.length > 0) {
|
|
112
|
+
lines.push('## API Issues');
|
|
113
|
+
lines.push('');
|
|
114
|
+
lines.push('| Endpoint | Error Rate | Calls | Avg Duration | Max Duration | Status Codes |');
|
|
115
|
+
lines.push('|----------|-----------|-------|-------------|-------------|-------------|');
|
|
116
|
+
for (const a of apiIssues.slice(0, 20)) {
|
|
117
|
+
lines.push(`| ${truncate(a.endpoint, 40)} | ${a.error_rate}% | ${a.total_calls} | ${formatDuration(a.avg_duration_ms)} | ${formatDuration(a.max_duration_ms)} | ${a.status_codes || '-'} |`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Error Patterns ──────────────────────────────────────────────────────────
|
|
123
|
+
if (errors.length > 0) {
|
|
124
|
+
lines.push('## Error Patterns');
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push('| Pattern | Category | Count | First Seen | Last Seen | Example Test |');
|
|
127
|
+
lines.push('|---------|----------|-------|------------|-----------|-------------|');
|
|
128
|
+
for (const e of errors.slice(0, 20)) {
|
|
129
|
+
lines.push(`| ${truncate(e.pattern, 50)} | ${e.category} | ${e.occurrence_count} | ${formatDate(e.first_seen)} | ${formatDate(e.last_seen)} | ${e.example_test || '-'} |`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Recent Trend ────────────────────────────────────────────────────────────
|
|
135
|
+
if (trends.length > 0) {
|
|
136
|
+
const label = trendsGranularity === 'hourly' ? 'Recent Trend (hourly)' : 'Recent Trend (7 days)';
|
|
137
|
+
const col1 = trendsGranularity === 'hourly' ? 'Hour' : 'Date';
|
|
138
|
+
lines.push(`## ${label}`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push(`| ${col1} | Pass Rate | Tests | Passed | Failed | Flaky | Avg Duration |`);
|
|
141
|
+
lines.push('|------|-----------|-------|--------|--------|-------|-------------|');
|
|
142
|
+
for (const t of trends) {
|
|
143
|
+
lines.push(`| ${t.date} | ${t.pass_rate}% | ${t.total_tests} | ${t.passed} | ${t.failed} | ${t.flaky_count} | ${formatDuration(t.avg_duration_ms)} |`);
|
|
144
|
+
}
|
|
145
|
+
lines.push('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Write the file
|
|
149
|
+
const cwd = config?._cwd || process.cwd();
|
|
150
|
+
const e2eDir = path.join(cwd, 'e2e');
|
|
151
|
+
if (!fs.existsSync(e2eDir)) {
|
|
152
|
+
fs.mkdirSync(e2eDir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const mdPath = path.join(e2eDir, 'learnings.md');
|
|
156
|
+
fs.writeFileSync(mdPath, lines.join('\n') + '\n');
|
|
157
|
+
|
|
158
|
+
return mdPath;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function formatDuration(ms) {
|
|
164
|
+
if (ms == null || isNaN(ms)) return '-';
|
|
165
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
166
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatDate(dateStr) {
|
|
170
|
+
if (!dateStr) return '-';
|
|
171
|
+
return dateStr.split('T')[0] || dateStr.slice(0, 10);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function truncate(str, max) {
|
|
175
|
+
if (!str) return '-';
|
|
176
|
+
return str.length > max ? str.slice(0, max - 3) + '...' : str;
|
|
177
|
+
}
|