@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.
- package/.claude-plugin/marketplace.json +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -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 +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -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 +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -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 +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/dashboard.js
CHANGED
|
@@ -14,13 +14,16 @@ import path from 'path';
|
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
15
|
import { createRequire } from 'module';
|
|
16
16
|
import { createWebSocketServer } from './websocket.js';
|
|
17
|
-
import {
|
|
17
|
+
import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool } from './pool-manager.js';
|
|
18
18
|
import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
|
|
19
19
|
import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.js';
|
|
20
|
-
import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, closeDb } from './db.js';
|
|
20
|
+
import { listProjects as dbListProjects, listProjectsWithSparklines as dbListProjectsWithSparklines, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, listVariables as dbListVariables, setVariable as dbSetVariable, deleteVariable as dbDeleteVariable, closeDb } from './db.js';
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { log, colors as C } from './logger.js';
|
|
23
|
-
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from './learner-sqlite.js';
|
|
23
|
+
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getHealthSnapshot, getActionHealthScores } from './learner-sqlite.js';
|
|
24
|
+
import { compareImages } from './visual-diff.js';
|
|
25
|
+
import { handleSyncRoutes } from './sync/hub-routes.js';
|
|
26
|
+
import { migrateSyncSchema } from './sync/schema.js';
|
|
24
27
|
|
|
25
28
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
26
29
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -38,6 +41,12 @@ export async function startDashboard(config) {
|
|
|
38
41
|
const port = config.dashboardPort || 8484;
|
|
39
42
|
const MAX_BODY = 1024 * 1024; // 1MB limit for POST bodies
|
|
40
43
|
const dashboardHtml = fs.readFileSync(path.join(__dirname, '..', 'templates', 'dashboard.html'), 'utf-8');
|
|
44
|
+
|
|
45
|
+
// Migrate sync schema if in hub mode
|
|
46
|
+
if (config.sync?.mode === 'hub') {
|
|
47
|
+
migrateSyncSchema();
|
|
48
|
+
log(`${C.cyan}[sync]${C.reset} Hub mode enabled`);
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
let currentRun = null; // { running: true, runId, report } or null
|
|
43
52
|
let latestReport = null;
|
|
@@ -86,7 +95,7 @@ export async function startDashboard(config) {
|
|
|
86
95
|
if (origin && allowedOrigins.includes(origin)) {
|
|
87
96
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
88
97
|
}
|
|
89
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
98
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
90
99
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
|
|
91
100
|
|
|
92
101
|
if (req.method === 'OPTIONS') {
|
|
@@ -96,6 +105,12 @@ export async function startDashboard(config) {
|
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
try {
|
|
108
|
+
// Handle sync routes if in hub mode
|
|
109
|
+
if (config.sync?.mode === 'hub' && pathname.startsWith('/api/sync')) {
|
|
110
|
+
const handled = await handleSyncRoutes(req, res, config, pathname);
|
|
111
|
+
if (handled) return;
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
// Serve dashboard HTML
|
|
100
115
|
if (pathname === '/' || pathname === '/index.html') {
|
|
101
116
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
@@ -105,9 +120,11 @@ export async function startDashboard(config) {
|
|
|
105
120
|
|
|
106
121
|
// API: pool status + dashboard state
|
|
107
122
|
if (pathname === '/api/status') {
|
|
108
|
-
const
|
|
123
|
+
const poolUrls = getPoolUrls(config);
|
|
124
|
+
const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
109
125
|
jsonResponse(res, {
|
|
110
|
-
pool:
|
|
126
|
+
pool: aggregated,
|
|
127
|
+
poolUrls,
|
|
111
128
|
dashboard: {
|
|
112
129
|
running: currentRun?.running || false,
|
|
113
130
|
wsClients: wss.clientCount,
|
|
@@ -115,8 +132,10 @@ export async function startDashboard(config) {
|
|
|
115
132
|
config: {
|
|
116
133
|
baseUrl: config.baseUrl,
|
|
117
134
|
poolUrl: config.poolUrl,
|
|
135
|
+
poolUrls,
|
|
118
136
|
concurrency: config.concurrency,
|
|
119
137
|
testsDir: config.testsDir,
|
|
138
|
+
sync: config.sync || { mode: 'standalone' },
|
|
120
139
|
},
|
|
121
140
|
});
|
|
122
141
|
return;
|
|
@@ -162,6 +181,17 @@ export async function startDashboard(config) {
|
|
|
162
181
|
return;
|
|
163
182
|
}
|
|
164
183
|
|
|
184
|
+
// API: DB — projects overview with sparklines (Watch view)
|
|
185
|
+
if (pathname === '/api/db/projects/overview') {
|
|
186
|
+
try {
|
|
187
|
+
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
188
|
+
jsonResponse(res, dbListProjectsWithSparklines(limit));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
165
195
|
// API: DB — runs for a project
|
|
166
196
|
const projectRunsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/runs$/);
|
|
167
197
|
if (projectRunsMatch) {
|
|
@@ -226,6 +256,88 @@ export async function startDashboard(config) {
|
|
|
226
256
|
return;
|
|
227
257
|
}
|
|
228
258
|
|
|
259
|
+
// API: DB — run insights (health + contextual insights)
|
|
260
|
+
const runInsightsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/insights$/);
|
|
261
|
+
if (runInsightsMatch) {
|
|
262
|
+
try {
|
|
263
|
+
const runDbId = parseInt(runInsightsMatch[1], 10);
|
|
264
|
+
const detail = dbGetRunDetail(runDbId);
|
|
265
|
+
if (!detail) { jsonResponse(res, { error: 'Run not found' }, 404); return; }
|
|
266
|
+
|
|
267
|
+
const projectId = detail.projectId || null;
|
|
268
|
+
const health = projectId ? getHealthSnapshot(projectId) : null;
|
|
269
|
+
|
|
270
|
+
// Build a minimal report object for getRunInsights
|
|
271
|
+
const results = (detail.results || []).map(r => ({
|
|
272
|
+
name: r.name,
|
|
273
|
+
success: r.success,
|
|
274
|
+
actions: r.actions || [],
|
|
275
|
+
}));
|
|
276
|
+
const insights = projectId ? getRunInsights(projectId, { results }) : [];
|
|
277
|
+
|
|
278
|
+
jsonResponse(res, { health, insights });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// API: DB — project health snapshot
|
|
286
|
+
const projectHealthMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/health$/);
|
|
287
|
+
if (projectHealthMatch) {
|
|
288
|
+
try {
|
|
289
|
+
const projectId = parseInt(projectHealthMatch[1], 10);
|
|
290
|
+
const health = getHealthSnapshot(projectId);
|
|
291
|
+
jsonResponse(res, health || {});
|
|
292
|
+
} catch (error) {
|
|
293
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// API: DB — cross-project health snapshot
|
|
299
|
+
if (pathname === '/api/db/health') {
|
|
300
|
+
try {
|
|
301
|
+
const health = getHealthSnapshot(null);
|
|
302
|
+
jsonResponse(res, health || {});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// API: DB — project config warnings (Docker hostname detection)
|
|
310
|
+
const configWarningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/config-warnings$/);
|
|
311
|
+
if (configWarningsMatch) {
|
|
312
|
+
try {
|
|
313
|
+
const projectId = parseInt(configWarningsMatch[1], 10);
|
|
314
|
+
const projectCwd = dbGetProjectCwd(projectId);
|
|
315
|
+
if (!projectCwd) { jsonResponse(res, { warnings: [] }); return; }
|
|
316
|
+
const cfg = await loadConfig({}, projectCwd);
|
|
317
|
+
const warnings = [];
|
|
318
|
+
const checkDockerHostname = (url, label) => {
|
|
319
|
+
try {
|
|
320
|
+
const parsed = new URL(url);
|
|
321
|
+
if (!parsed.hostname.includes('.') && parsed.hostname !== 'localhost' && parsed.hostname !== '127') {
|
|
322
|
+
warnings.push({
|
|
323
|
+
type: 'docker-hostname',
|
|
324
|
+
field: label,
|
|
325
|
+
hostname: parsed.hostname,
|
|
326
|
+
url,
|
|
327
|
+
message: `"${parsed.hostname}" looks like a Docker-internal hostname. The runner will auto-fallback to localhost for auth, but baseUrl requests go through Chrome in Docker (which can resolve it). If tests fail with ENOTFOUND, ensure Chrome pool is on the same Docker network.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
};
|
|
332
|
+
if (cfg.baseUrl) checkDockerHostname(cfg.baseUrl, 'baseUrl');
|
|
333
|
+
if (cfg.authLoginEndpoint) checkDockerHostname(cfg.authLoginEndpoint, 'authLoginEndpoint');
|
|
334
|
+
jsonResponse(res, { warnings });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
jsonResponse(res, { warnings: [], error: error.message });
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
229
341
|
// API: DB — project screenshots list
|
|
230
342
|
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
231
343
|
if (projectScreenshotsMatch) {
|
|
@@ -334,6 +446,57 @@ export async function startDashboard(config) {
|
|
|
334
446
|
return;
|
|
335
447
|
}
|
|
336
448
|
|
|
449
|
+
// API: DB — project variables (list)
|
|
450
|
+
const projectVarsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables$/);
|
|
451
|
+
if (projectVarsMatch && req.method === 'GET') {
|
|
452
|
+
try {
|
|
453
|
+
const projectId = parseInt(projectVarsMatch[1], 10);
|
|
454
|
+
jsonResponse(res, dbListVariables(projectId));
|
|
455
|
+
} catch (error) {
|
|
456
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// API: DB — project variables (set/upsert)
|
|
462
|
+
if (projectVarsMatch && req.method === 'PUT') {
|
|
463
|
+
let body = '';
|
|
464
|
+
let oversize = false;
|
|
465
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
|
|
466
|
+
req.on('end', () => {
|
|
467
|
+
if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
|
|
468
|
+
try {
|
|
469
|
+
const projectId = parseInt(projectVarsMatch[1], 10);
|
|
470
|
+
const { scope, key, value } = JSON.parse(body);
|
|
471
|
+
if (!key || value === undefined) { jsonResponse(res, { error: 'Missing key or value' }, 400); return; }
|
|
472
|
+
dbSetVariable(projectId, scope || 'project', key, value);
|
|
473
|
+
jsonResponse(res, { ok: true });
|
|
474
|
+
} catch (error) {
|
|
475
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// API: DB — project variables (delete)
|
|
482
|
+
const varDeleteMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables\/([^/]+)\/([^/]+)$/);
|
|
483
|
+
if (varDeleteMatch && req.method === 'DELETE') {
|
|
484
|
+
try {
|
|
485
|
+
const projectId = parseInt(varDeleteMatch[1], 10);
|
|
486
|
+
const scope = decodeURIComponent(varDeleteMatch[2]);
|
|
487
|
+
const key = decodeURIComponent(varDeleteMatch[3]);
|
|
488
|
+
const deleted = dbDeleteVariable(projectId, scope, key);
|
|
489
|
+
if (deleted) {
|
|
490
|
+
jsonResponse(res, { ok: true });
|
|
491
|
+
} else {
|
|
492
|
+
jsonResponse(res, { error: 'Variable not found' }, 404);
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
337
500
|
// API: DB — project learnings (summary or specific category)
|
|
338
501
|
const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
|
|
339
502
|
if (learningsMatch) {
|
|
@@ -368,6 +531,9 @@ export async function startDashboard(config) {
|
|
|
368
531
|
case 'trends':
|
|
369
532
|
data = getTestTrends(projectId, days);
|
|
370
533
|
break;
|
|
534
|
+
case 'actions':
|
|
535
|
+
data = getActionHealthScores(projectId, days);
|
|
536
|
+
break;
|
|
371
537
|
default:
|
|
372
538
|
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
373
539
|
return;
|
|
@@ -379,6 +545,30 @@ export async function startDashboard(config) {
|
|
|
379
545
|
return;
|
|
380
546
|
}
|
|
381
547
|
|
|
548
|
+
// API: visual diff — compare two screenshots on demand
|
|
549
|
+
if (pathname === '/api/visual-diff') {
|
|
550
|
+
try {
|
|
551
|
+
const baseline = url.searchParams.get('baseline');
|
|
552
|
+
const current = url.searchParams.get('current');
|
|
553
|
+
const thresholdParam = url.searchParams.get('threshold');
|
|
554
|
+
if (!baseline || !current) {
|
|
555
|
+
jsonResponse(res, { error: 'Missing baseline or current parameter' }, 400); return;
|
|
556
|
+
}
|
|
557
|
+
if (!fs.existsSync(baseline)) { jsonResponse(res, { error: `Baseline not found: ${baseline}` }, 404); return; }
|
|
558
|
+
if (!fs.existsSync(current)) { jsonResponse(res, { error: `Current not found: ${current}` }, 404); return; }
|
|
559
|
+
|
|
560
|
+
const diffPath = path.join(config.screenshotsDir, `api-diff-${Date.now()}.png`);
|
|
561
|
+
const result = compareImages(baseline, current, {
|
|
562
|
+
threshold: thresholdParam ? parseFloat(thresholdParam) : 0.1,
|
|
563
|
+
diffOutputPath: diffPath,
|
|
564
|
+
});
|
|
565
|
+
jsonResponse(res, { ...result, diffImagePath: diffPath });
|
|
566
|
+
} catch (error) {
|
|
567
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
382
572
|
// API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
|
|
383
573
|
const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
|
|
384
574
|
if (ssHashMatch) {
|
|
@@ -533,7 +723,9 @@ export async function startDashboard(config) {
|
|
|
533
723
|
if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
|
|
534
724
|
try {
|
|
535
725
|
const data = JSON.parse(body);
|
|
536
|
-
|
|
726
|
+
if (data.event !== 'test:frame') {
|
|
727
|
+
bufferLiveEvent(data);
|
|
728
|
+
}
|
|
537
729
|
wss.broadcast(JSON.stringify(data));
|
|
538
730
|
} catch { /* */ }
|
|
539
731
|
jsonResponse(res, { ok: true });
|
|
@@ -581,8 +773,9 @@ export async function startDashboard(config) {
|
|
|
581
773
|
}
|
|
582
774
|
}, 30000);
|
|
583
775
|
|
|
776
|
+
const devOrigins = process.env.NODE_ENV === 'production' ? [] : ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
584
777
|
const wss = createWebSocketServer(server, {
|
|
585
|
-
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}
|
|
778
|
+
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`, ...devOrigins],
|
|
586
779
|
onConnect(socket) {
|
|
587
780
|
// Replay live state for new/reconnected clients
|
|
588
781
|
for (const rid of Object.keys(liveEventBuffers)) {
|
|
@@ -593,11 +786,12 @@ export async function startDashboard(config) {
|
|
|
593
786
|
},
|
|
594
787
|
});
|
|
595
788
|
|
|
596
|
-
// Pool status polling
|
|
789
|
+
// Pool status polling (aggregated across all pools)
|
|
790
|
+
const poolUrls = getPoolUrls(config);
|
|
597
791
|
const pollInterval = setInterval(async () => {
|
|
598
792
|
try {
|
|
599
|
-
const
|
|
600
|
-
wss.broadcast(JSON.stringify({ event: 'pool:status', data:
|
|
793
|
+
const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
794
|
+
wss.broadcast(JSON.stringify({ event: 'pool:status', data: aggregated }));
|
|
601
795
|
} catch { /* */ }
|
|
602
796
|
}, 5000);
|
|
603
797
|
|
|
@@ -624,7 +818,8 @@ export async function startDashboard(config) {
|
|
|
624
818
|
const projectCwd = dbGetProjectCwd(params.projectId);
|
|
625
819
|
if (!projectCwd) throw new Error('Project not found');
|
|
626
820
|
runConfig = await loadConfig({}, projectCwd);
|
|
627
|
-
// Inherit pool
|
|
821
|
+
// Inherit pool URLs from dashboard config (pool is shared)
|
|
822
|
+
runConfig._poolUrls = getPoolUrls(config);
|
|
628
823
|
runConfig.poolUrl = config.poolUrl;
|
|
629
824
|
} else {
|
|
630
825
|
runConfig = { ...config };
|
|
@@ -633,26 +828,30 @@ export async function startDashboard(config) {
|
|
|
633
828
|
runConfig.triggeredBy = 'dashboard';
|
|
634
829
|
if (params.concurrency) runConfig.concurrency = params.concurrency;
|
|
635
830
|
if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
|
|
831
|
+
if (params.screencast !== undefined) runConfig.screencast = params.screencast;
|
|
636
832
|
|
|
637
833
|
// Wire up onProgress to broadcast WS events
|
|
638
834
|
runConfig.onProgress = (data) => {
|
|
639
|
-
|
|
835
|
+
// Don't buffer screencast frames — they're ephemeral and high volume
|
|
836
|
+
if (data.event !== 'test:frame') {
|
|
837
|
+
bufferLiveEvent(data);
|
|
838
|
+
}
|
|
640
839
|
wss.broadcast(JSON.stringify(data));
|
|
641
840
|
};
|
|
642
841
|
|
|
643
842
|
let tests, hooks;
|
|
644
843
|
if (params.suite) {
|
|
645
|
-
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
|
|
844
|
+
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir, runConfig.modulesDir));
|
|
646
845
|
} else {
|
|
647
846
|
({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
|
|
648
847
|
}
|
|
649
848
|
|
|
650
|
-
await
|
|
849
|
+
await waitForAnyPool(getPoolUrls(runConfig), 30000, { poolDriver: runConfig.poolDriver, maxSessions: runConfig.maxSessions });
|
|
651
850
|
const results = await runTestsParallel(tests, runConfig, hooks || {});
|
|
652
851
|
const report = generateReport(results);
|
|
653
852
|
const suiteName = params.suite || null;
|
|
654
853
|
saveReport(report, runConfig.screenshotsDir, runConfig);
|
|
655
|
-
persistRun(report, runConfig, suiteName);
|
|
854
|
+
await persistRun(report, runConfig, suiteName);
|
|
656
855
|
latestReport = report;
|
|
657
856
|
currentRun = { running: false };
|
|
658
857
|
} catch (error) {
|
|
@@ -662,7 +861,7 @@ export async function startDashboard(config) {
|
|
|
662
861
|
}
|
|
663
862
|
|
|
664
863
|
return new Promise((resolve, reject) => {
|
|
665
|
-
const host = config.dashboardHost || '
|
|
864
|
+
const host = config.dashboardHost || process.env.DASHBOARD_HOST || '0.0.0.0';
|
|
666
865
|
|
|
667
866
|
server.on('error', (err) => {
|
|
668
867
|
if (err.code === 'EADDRINUSE') {
|
package/src/db.js
CHANGED
|
@@ -199,6 +199,24 @@ function migrate(db) {
|
|
|
199
199
|
CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
|
|
200
200
|
CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
|
|
201
201
|
|
|
202
|
+
CREATE TABLE IF NOT EXISTS action_health (
|
|
203
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
205
|
+
run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
|
|
206
|
+
test_name TEXT NOT NULL,
|
|
207
|
+
action_index INTEGER NOT NULL,
|
|
208
|
+
action_type TEXT NOT NULL,
|
|
209
|
+
selector TEXT,
|
|
210
|
+
success INTEGER NOT NULL,
|
|
211
|
+
duration_ms INTEGER,
|
|
212
|
+
console_errors_after INTEGER DEFAULT 0,
|
|
213
|
+
network_errors_after INTEGER DEFAULT 0,
|
|
214
|
+
page_url TEXT,
|
|
215
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
216
|
+
);
|
|
217
|
+
CREATE INDEX IF NOT EXISTS idx_ah_project ON action_health(project_id);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_ah_selector ON action_health(selector);
|
|
219
|
+
|
|
202
220
|
CREATE TABLE IF NOT EXISTS error_patterns (
|
|
203
221
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
204
222
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
@@ -231,6 +249,52 @@ function migrate(db) {
|
|
|
231
249
|
);
|
|
232
250
|
`);
|
|
233
251
|
|
|
252
|
+
// ── Variables table ──────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
db.exec(`
|
|
255
|
+
CREATE TABLE IF NOT EXISTS variables (
|
|
256
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
257
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
258
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
259
|
+
key TEXT NOT NULL,
|
|
260
|
+
value TEXT NOT NULL,
|
|
261
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
262
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
263
|
+
UNIQUE(project_id, scope, key)
|
|
264
|
+
);
|
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_vars_project ON variables(project_id);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_vars_scope ON variables(project_id, scope);
|
|
267
|
+
`);
|
|
268
|
+
|
|
269
|
+
// Add pool_url column for multi-pool tracking
|
|
270
|
+
try {
|
|
271
|
+
db.prepare('SELECT pool_url FROM test_results LIMIT 0').run();
|
|
272
|
+
} catch {
|
|
273
|
+
db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Add pool_driver column to runs for driver visibility
|
|
277
|
+
try {
|
|
278
|
+
db.prepare('SELECT pool_driver FROM runs LIMIT 0').run();
|
|
279
|
+
} catch {
|
|
280
|
+
db.exec('ALTER TABLE runs ADD COLUMN pool_driver TEXT');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Add visual diff columns to test_results
|
|
284
|
+
const trCols = db.pragma('table_info(test_results)').map(c => c.name);
|
|
285
|
+
if (!trCols.includes('baseline_screenshot')) {
|
|
286
|
+
db.exec('ALTER TABLE test_results ADD COLUMN baseline_screenshot TEXT');
|
|
287
|
+
}
|
|
288
|
+
if (!trCols.includes('verification_screenshot')) {
|
|
289
|
+
db.exec('ALTER TABLE test_results ADD COLUMN verification_screenshot TEXT');
|
|
290
|
+
}
|
|
291
|
+
if (!trCols.includes('diff_screenshot')) {
|
|
292
|
+
db.exec('ALTER TABLE test_results ADD COLUMN diff_screenshot TEXT');
|
|
293
|
+
}
|
|
294
|
+
if (!trCols.includes('visual_diff_json')) {
|
|
295
|
+
db.exec('ALTER TABLE test_results ADD COLUMN visual_diff_json TEXT');
|
|
296
|
+
}
|
|
297
|
+
|
|
234
298
|
// Migrations: add metadata columns to screenshot_hashes
|
|
235
299
|
const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
|
|
236
300
|
if (!ssColumns.includes('test_name')) {
|
|
@@ -312,18 +376,18 @@ export function getScreenshotHashes(filePaths) {
|
|
|
312
376
|
}
|
|
313
377
|
|
|
314
378
|
/** Save a run + its test results in a single transaction. Returns the run's DB id. */
|
|
315
|
-
export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
379
|
+
export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDriver) {
|
|
316
380
|
const d = getDb();
|
|
317
381
|
const { summary, results, generatedAt } = report;
|
|
318
382
|
|
|
319
383
|
const insertRun = d.prepare(`
|
|
320
|
-
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
|
|
321
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
384
|
+
INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver)
|
|
385
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
322
386
|
`);
|
|
323
387
|
|
|
324
388
|
const insertTest = d.prepare(`
|
|
325
|
-
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)
|
|
326
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
389
|
+
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, baseline_screenshot, verification_screenshot, diff_screenshot, visual_diff_json)
|
|
390
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
327
391
|
`);
|
|
328
392
|
|
|
329
393
|
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 (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
@@ -340,6 +404,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
340
404
|
generatedAt,
|
|
341
405
|
suiteName || null,
|
|
342
406
|
triggeredBy || null,
|
|
407
|
+
poolDriver || null,
|
|
343
408
|
);
|
|
344
409
|
const runDbId = runInfo.lastInsertRowid;
|
|
345
410
|
|
|
@@ -382,6 +447,11 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
382
447
|
screenshots.length ? JSON.stringify(screenshots) : null,
|
|
383
448
|
r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
|
|
384
449
|
actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
|
|
450
|
+
r.poolUrl || null,
|
|
451
|
+
r.baselineScreenshot || null,
|
|
452
|
+
r.verificationScreenshot || null,
|
|
453
|
+
r.diffScreenshot || null,
|
|
454
|
+
r.visualDiff ? JSON.stringify(r.visualDiff) : null,
|
|
385
455
|
);
|
|
386
456
|
|
|
387
457
|
// Register screenshot hashes with metadata
|
|
@@ -397,6 +467,12 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
397
467
|
if (r.verificationScreenshot) {
|
|
398
468
|
insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
|
|
399
469
|
}
|
|
470
|
+
if (r.baselineScreenshot) {
|
|
471
|
+
insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
|
|
472
|
+
}
|
|
473
|
+
if (r.diffScreenshot) {
|
|
474
|
+
insertHash.run(computeScreenshotHash(r.diffScreenshot), r.diffScreenshot, projectId, runDbId, r.name, null, null, 'diff');
|
|
475
|
+
}
|
|
400
476
|
}
|
|
401
477
|
|
|
402
478
|
return runDbId;
|
|
@@ -405,6 +481,33 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
|
|
|
405
481
|
return tx();
|
|
406
482
|
}
|
|
407
483
|
|
|
484
|
+
/** Save a run from sync (remote instance). Returns the run's DB id. */
|
|
485
|
+
export function persistRunFromSync({ projectId, runId, total, passed, failed, passRate, duration, generatedAt, suiteName, triggeredBy, syncInstanceId, syncOrigin }) {
|
|
486
|
+
const d = getDb();
|
|
487
|
+
|
|
488
|
+
const stmt = d.prepare(`
|
|
489
|
+
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)
|
|
490
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
491
|
+
`);
|
|
492
|
+
|
|
493
|
+
const result = stmt.run(
|
|
494
|
+
projectId,
|
|
495
|
+
runId,
|
|
496
|
+
total,
|
|
497
|
+
passed,
|
|
498
|
+
failed,
|
|
499
|
+
passRate,
|
|
500
|
+
duration,
|
|
501
|
+
generatedAt,
|
|
502
|
+
suiteName || null,
|
|
503
|
+
triggeredBy || null,
|
|
504
|
+
syncInstanceId || null,
|
|
505
|
+
syncOrigin || 'remote'
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
return result.lastInsertRowid;
|
|
509
|
+
}
|
|
510
|
+
|
|
408
511
|
/** List all projects with aggregated stats. */
|
|
409
512
|
export function listProjects() {
|
|
410
513
|
const d = getDb();
|
|
@@ -425,7 +528,7 @@ export function listProjects() {
|
|
|
425
528
|
export function getProjectRuns(projectId, limit = 50, offset = 0) {
|
|
426
529
|
const d = getDb();
|
|
427
530
|
return d.prepare(`
|
|
428
|
-
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
|
|
531
|
+
SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver
|
|
429
532
|
FROM runs
|
|
430
533
|
WHERE project_id = ?
|
|
431
534
|
ORDER BY generated_at DESC
|
|
@@ -448,6 +551,9 @@ export function getRunDetail(runDbId) {
|
|
|
448
551
|
const ss = t.screenshots ? JSON.parse(t.screenshots) : [];
|
|
449
552
|
allPaths.push(...ss);
|
|
450
553
|
if (t.error_screenshot) allPaths.push(t.error_screenshot);
|
|
554
|
+
if (t.baseline_screenshot) allPaths.push(t.baseline_screenshot);
|
|
555
|
+
if (t.verification_screenshot) allPaths.push(t.verification_screenshot);
|
|
556
|
+
if (t.diff_screenshot) allPaths.push(t.diff_screenshot);
|
|
451
557
|
}
|
|
452
558
|
const hashMap = getScreenshotHashes(allPaths);
|
|
453
559
|
|
|
@@ -463,6 +569,7 @@ export function getRunDetail(runDbId) {
|
|
|
463
569
|
generatedAt: run.generated_at,
|
|
464
570
|
suiteName: run.suite_name,
|
|
465
571
|
triggeredBy: run.triggered_by || null,
|
|
572
|
+
poolDriver: run.pool_driver || null,
|
|
466
573
|
results: tests.map(t => {
|
|
467
574
|
const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
|
|
468
575
|
const testPaths = [...screenshots];
|
|
@@ -487,6 +594,11 @@ export function getRunDetail(runDbId) {
|
|
|
487
594
|
networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
|
|
488
595
|
actions: t.actions_json ? JSON.parse(t.actions_json) : [],
|
|
489
596
|
screenshotHashes,
|
|
597
|
+
poolUrl: t.pool_url || null,
|
|
598
|
+
baselineScreenshot: t.baseline_screenshot || null,
|
|
599
|
+
verificationScreenshot: t.verification_screenshot || null,
|
|
600
|
+
diffScreenshot: t.diff_screenshot || null,
|
|
601
|
+
visualDiff: t.visual_diff_json ? JSON.parse(t.visual_diff_json) : null,
|
|
490
602
|
};
|
|
491
603
|
}),
|
|
492
604
|
};
|
|
@@ -497,7 +609,7 @@ export function getAllRuns(limit = 50, offset = 0) {
|
|
|
497
609
|
const d = getDb();
|
|
498
610
|
return d.prepare(`
|
|
499
611
|
SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
|
|
500
|
-
r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
|
|
612
|
+
r.generated_at, r.suite_name, r.triggered_by, r.pool_driver, p.name AS project_name, p.id AS project_id
|
|
501
613
|
FROM runs r
|
|
502
614
|
JOIN projects p ON p.id = r.project_id
|
|
503
615
|
ORDER BY r.generated_at DESC
|
|
@@ -572,7 +684,79 @@ export function getNetworkLogs(runDbId, filters = {}) {
|
|
|
572
684
|
return results;
|
|
573
685
|
}
|
|
574
686
|
|
|
687
|
+
// ── Variables CRUD ────────────────────────────────────────────────────────────
|
|
688
|
+
|
|
689
|
+
/** Upsert a variable. Scope is 'project' or a suite name. */
|
|
690
|
+
export function setVariable(projectId, scope, key, value) {
|
|
691
|
+
const d = getDb();
|
|
692
|
+
d.prepare(`
|
|
693
|
+
INSERT INTO variables (project_id, scope, key, value, updated_at)
|
|
694
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
695
|
+
ON CONFLICT(project_id, scope, key)
|
|
696
|
+
DO UPDATE SET value = excluded.value, updated_at = datetime('now')
|
|
697
|
+
`).run(projectId, scope, key, value);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Get variables for a specific scope. Returns { key: value } map. */
|
|
701
|
+
export function getVariables(projectId, scope) {
|
|
702
|
+
const d = getDb();
|
|
703
|
+
const rows = d.prepare('SELECT key, value FROM variables WHERE project_id = ? AND scope = ?').all(projectId, scope);
|
|
704
|
+
const map = {};
|
|
705
|
+
for (const r of rows) map[r.key] = r.value;
|
|
706
|
+
return map;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Delete a variable. Returns true if deleted. */
|
|
710
|
+
export function deleteVariable(projectId, scope, key) {
|
|
711
|
+
const d = getDb();
|
|
712
|
+
const info = d.prepare('DELETE FROM variables WHERE project_id = ? AND scope = ? AND key = ?').run(projectId, scope, key);
|
|
713
|
+
return info.changes > 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** List all variables for a project, grouped by scope. Returns { scope: { key: value } }. */
|
|
717
|
+
export function listVariables(projectId) {
|
|
718
|
+
const d = getDb();
|
|
719
|
+
const rows = d.prepare('SELECT scope, key, value FROM variables WHERE project_id = ? ORDER BY scope, key').all(projectId);
|
|
720
|
+
const grouped = {};
|
|
721
|
+
for (const r of rows) {
|
|
722
|
+
if (!grouped[r.scope]) grouped[r.scope] = {};
|
|
723
|
+
grouped[r.scope][r.key] = r.value;
|
|
724
|
+
}
|
|
725
|
+
return grouped;
|
|
726
|
+
}
|
|
727
|
+
|
|
575
728
|
/** Close the database connection. */
|
|
729
|
+
/** Projects with sparkline data (last N run pass rates, oldest→newest). */
|
|
730
|
+
export function listProjectsWithSparklines(sparklineSize = 20) {
|
|
731
|
+
const d = getDb();
|
|
732
|
+
const projects = d.prepare(`
|
|
733
|
+
SELECT
|
|
734
|
+
p.id, p.cwd, p.name, p.screenshots_dir, p.tests_dir, p.created_at, p.updated_at,
|
|
735
|
+
COUNT(r.id) AS runCount,
|
|
736
|
+
MAX(r.generated_at) AS lastRunAt,
|
|
737
|
+
(SELECT r2.pass_rate FROM runs r2 WHERE r2.project_id = p.id ORDER BY r2.generated_at DESC LIMIT 1) AS lastPassRate
|
|
738
|
+
FROM projects p
|
|
739
|
+
LEFT JOIN runs r ON r.project_id = p.id
|
|
740
|
+
GROUP BY p.id
|
|
741
|
+
ORDER BY p.updated_at DESC
|
|
742
|
+
`).all();
|
|
743
|
+
|
|
744
|
+
const sparkStmt = d.prepare(`
|
|
745
|
+
SELECT CAST(pass_rate AS REAL) AS rate
|
|
746
|
+
FROM runs
|
|
747
|
+
WHERE project_id = ?
|
|
748
|
+
ORDER BY generated_at DESC
|
|
749
|
+
LIMIT ?
|
|
750
|
+
`);
|
|
751
|
+
|
|
752
|
+
return projects.map(p => {
|
|
753
|
+
const rawRates = sparkStmt.all(p.id, sparklineSize).map(r => r.rate);
|
|
754
|
+
// Reverse to oldest→newest
|
|
755
|
+
rawRates.reverse();
|
|
756
|
+
return { ...p, sparkline: rawRates };
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
576
760
|
export function closeDb() {
|
|
577
761
|
if (db) {
|
|
578
762
|
db.close();
|