plum-e2e 2.5.12 → 2.5.14
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/backend/mcp/server.js +112 -1
- package/backend/prisma/migrations/20260711000000_add_project_timezone/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- package/backend/routes/node.routes.js +1 -1
- package/backend/routes/settings.routes.js +2 -2
- package/backend/services/backupCronService.js +4 -4
- package/backend/services/backupService.js +1 -0
- package/backend/services/cronService.js +9 -6
- package/backend/services/runnerService.js +3 -1
- package/backend/services/settingsService.js +18 -4
- package/frontend/src/lib/api/settings.js +3 -3
- package/frontend/src/lib/constants.js +1 -0
- package/frontend/src/routes/settings/+page.svelte +21 -1
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +48 -1
- package/package.json +1 -1
package/backend/mcp/server.js
CHANGED
|
@@ -88,7 +88,9 @@ async function pollJob(jobId, { maxMs = 600_000, intervalMs = 5_000 } = {}) {
|
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
|
|
90
90
|
function summariseReport(report) {
|
|
91
|
-
|
|
91
|
+
// GET /reports/:id hoists content.features to a top-level `features` key
|
|
92
|
+
// and strips `content` entirely — see reportService.getReportDetail.
|
|
93
|
+
const features = report.features ?? report.content?.features ?? [];
|
|
92
94
|
const allScenarios = features.flatMap((f) => f.scenarios ?? []);
|
|
93
95
|
const total = allScenarios.length;
|
|
94
96
|
const passed = allScenarios.filter((s) => s.status === 'passed').length;
|
|
@@ -117,6 +119,18 @@ function summariseReport(report) {
|
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Screenshots
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const SCREENSHOT_FILENAME_RE = /^[\w.-]+\.(png|jpg|jpeg)$/i;
|
|
127
|
+
|
|
128
|
+
function screenshotUrl(filename) {
|
|
129
|
+
return `${API_URL}/screenshots/${filename}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SCREENSHOT_MIME_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
|
|
133
|
+
|
|
120
134
|
// ---------------------------------------------------------------------------
|
|
121
135
|
// Server setup
|
|
122
136
|
// ---------------------------------------------------------------------------
|
|
@@ -437,6 +451,103 @@ server.tool(
|
|
|
437
451
|
}
|
|
438
452
|
);
|
|
439
453
|
|
|
454
|
+
server.tool(
|
|
455
|
+
'get_report_scenario_detail',
|
|
456
|
+
[
|
|
457
|
+
'Get full per-scenario, per-step detail for a Plum test report — the data needed to diagnose',
|
|
458
|
+
"and self-heal a failing test: every step's status, duration, full error message, and",
|
|
459
|
+
'screenshot URL (if one was captured for that step).',
|
|
460
|
+
'',
|
|
461
|
+
'Use get_report_screenshot to fetch the actual screenshot image for a filename returned here,',
|
|
462
|
+
'and get_report_logs for the raw test-run stdout/stderr.'
|
|
463
|
+
].join('\n'),
|
|
464
|
+
{
|
|
465
|
+
reportId: z.number().int().describe('Numeric report ID'),
|
|
466
|
+
onlyFailed: z
|
|
467
|
+
.boolean()
|
|
468
|
+
.optional()
|
|
469
|
+
.describe('Only include scenarios with a failed step (default true)')
|
|
470
|
+
},
|
|
471
|
+
async ({ reportId, onlyFailed = true }) => {
|
|
472
|
+
const data = await get(`/reports/${reportId}`);
|
|
473
|
+
const features = data.features ?? data.content?.features ?? [];
|
|
474
|
+
|
|
475
|
+
const scenarios = features.flatMap((feature) =>
|
|
476
|
+
(feature.scenarios ?? [])
|
|
477
|
+
.filter((s) => !onlyFailed || s.status === 'failed')
|
|
478
|
+
.map((s) => ({
|
|
479
|
+
feature: feature.name,
|
|
480
|
+
scenario: s.name,
|
|
481
|
+
tags: s.tags ?? [],
|
|
482
|
+
status: s.status,
|
|
483
|
+
duration: s.duration,
|
|
484
|
+
steps: (s.steps ?? []).map((st) => ({
|
|
485
|
+
keyword: st.keyword,
|
|
486
|
+
name: st.name,
|
|
487
|
+
status: st.status,
|
|
488
|
+
duration: st.duration,
|
|
489
|
+
error: st.error ?? null,
|
|
490
|
+
screenshot: st.screenshot ?? null,
|
|
491
|
+
screenshotUrl: st.screenshot ? screenshotUrl(st.screenshot) : null
|
|
492
|
+
}))
|
|
493
|
+
}))
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
return { content: [{ type: 'text', text: JSON.stringify({ reportId, scenarios }, null, 2) }] };
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
server.tool(
|
|
501
|
+
'get_report_screenshot',
|
|
502
|
+
'Fetch a screenshot captured during a test step and return it as an image, so it can be viewed ' +
|
|
503
|
+
"directly. Get the filename from get_report_scenario_detail's step.screenshot field.",
|
|
504
|
+
{
|
|
505
|
+
filename: z.string().describe('Screenshot filename, e.g. "3f9c1e2a-....png"')
|
|
506
|
+
},
|
|
507
|
+
async ({ filename }) => {
|
|
508
|
+
if (!SCREENSHOT_FILENAME_RE.test(filename)) {
|
|
509
|
+
throw new Error(`Invalid screenshot filename: ${filename}`);
|
|
510
|
+
}
|
|
511
|
+
const res = await fetch(screenshotUrl(filename));
|
|
512
|
+
if (!res.ok) throw new Error(`Screenshot not found: ${filename}`);
|
|
513
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
514
|
+
const ext = filename.split('.').pop().toLowerCase();
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
type: 'image',
|
|
520
|
+
data: buffer.toString('base64'),
|
|
521
|
+
mimeType: SCREENSHOT_MIME_TYPES[ext]
|
|
522
|
+
}
|
|
523
|
+
]
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
server.tool(
|
|
529
|
+
'get_report_logs',
|
|
530
|
+
'Get the raw stdout/stderr log output captured during a Plum test run, tagged per runner. ' +
|
|
531
|
+
'Useful for diagnosing failures with no clear step-level error (crashes, timeouts, setup errors).',
|
|
532
|
+
{
|
|
533
|
+
reportId: z.number().int().describe('Numeric report ID'),
|
|
534
|
+
tail: z
|
|
535
|
+
.number()
|
|
536
|
+
.int()
|
|
537
|
+
.positive()
|
|
538
|
+
.optional()
|
|
539
|
+
.describe('Only return the last N lines (default: full log)')
|
|
540
|
+
},
|
|
541
|
+
async ({ reportId, tail }) => {
|
|
542
|
+
const data = await get(`/reports/${reportId}`);
|
|
543
|
+
let logs = data.logs ?? '';
|
|
544
|
+
if (tail) {
|
|
545
|
+
logs = logs.split('\n').slice(-tail).join('\n');
|
|
546
|
+
}
|
|
547
|
+
return { content: [{ type: 'text', text: logs || '(no logs captured for this report)' }] };
|
|
548
|
+
}
|
|
549
|
+
);
|
|
550
|
+
|
|
440
551
|
// ---------------------------------------------------------------------------
|
|
441
552
|
// Start
|
|
442
553
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +60,7 @@ router.post('/execute', authGuard, (req, res) => {
|
|
|
60
60
|
for (const [rel, content] of Object.entries(tests)) {
|
|
61
61
|
const dest = path.join(tempTestsDir, rel);
|
|
62
62
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
63
|
-
fs.writeFileSync(dest, content, '
|
|
63
|
+
fs.writeFileSync(dest, Buffer.from(content, 'base64'));
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -33,8 +33,8 @@ router.get('/project', async (req, res, next) => {
|
|
|
33
33
|
|
|
34
34
|
router.post('/project', async (req, res, next) => {
|
|
35
35
|
try {
|
|
36
|
-
const { name, logoUrl } = req.body;
|
|
37
|
-
const project = await settingsService.updateProject({ name, logoUrl });
|
|
36
|
+
const { name, logoUrl, timezone } = req.body;
|
|
37
|
+
const project = await settingsService.updateProject({ name, logoUrl, timezone });
|
|
38
38
|
res.json(project);
|
|
39
39
|
} catch (e) {
|
|
40
40
|
next(e);
|
|
@@ -58,20 +58,20 @@ const runBackup = async () => {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
const schedule = (cronExpr, enabled) => {
|
|
61
|
+
const schedule = (cronExpr, enabled, timezone) => {
|
|
62
62
|
if (scheduledJob) {
|
|
63
63
|
scheduledJob.stop();
|
|
64
64
|
scheduledJob = null;
|
|
65
65
|
}
|
|
66
66
|
if (!enabled || !cronExpr || !cron.validate(cronExpr)) return;
|
|
67
|
-
scheduledJob = cron.schedule(cronExpr, runBackup);
|
|
68
|
-
console.log(`⏰ Backup scheduled: ${cronExpr}`);
|
|
67
|
+
scheduledJob = cron.schedule(cronExpr, runBackup, { timezone: timezone || 'UTC' });
|
|
68
|
+
console.log(`⏰ Backup scheduled: ${cronExpr} (${timezone || 'UTC'})`);
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
const init = async () => {
|
|
72
72
|
try {
|
|
73
73
|
const project = await prisma.project.findUnique({ where: { id: 1 } });
|
|
74
|
-
schedule(project?.backupCron, project?.backupEnabled);
|
|
74
|
+
schedule(project?.backupCron, project?.backupEnabled, project?.timezone);
|
|
75
75
|
} catch (err) {
|
|
76
76
|
console.error('Failed to initialize backup cron:', err.message);
|
|
77
77
|
}
|
|
@@ -244,19 +244,22 @@ async function runCronJob(job) {
|
|
|
244
244
|
// Public: scheduling
|
|
245
245
|
// ---------------------------------------------------------------------------
|
|
246
246
|
|
|
247
|
-
function scheduleJob(job) {
|
|
247
|
+
async function scheduleJob(job) {
|
|
248
248
|
const { taskName, cronExpression } = job;
|
|
249
249
|
if (scheduledJobs[taskName]) {
|
|
250
250
|
scheduledJobs[taskName].stop();
|
|
251
251
|
delete scheduledJobs[taskName];
|
|
252
252
|
}
|
|
253
253
|
if (job.enabled === false) return; // disabled jobs are not scheduled
|
|
254
|
-
|
|
254
|
+
const project = await prisma.project.findUnique({ where: { id: 1 } });
|
|
255
|
+
scheduledJobs[taskName] = cron.schedule(cronExpression, () => runCronJob(job), {
|
|
256
|
+
timezone: project?.timezone || 'UTC'
|
|
257
|
+
});
|
|
255
258
|
}
|
|
256
259
|
|
|
257
260
|
const init = async () => {
|
|
258
261
|
const jobs = await prisma.cronJob.findMany();
|
|
259
|
-
for (const job of jobs) scheduleJob(job);
|
|
262
|
+
for (const job of jobs) await scheduleJob(job);
|
|
260
263
|
console.log(`⏰ Scheduled ${jobs.length} cron job(s) from database`);
|
|
261
264
|
};
|
|
262
265
|
|
|
@@ -303,7 +306,7 @@ const addCronJob = async ({
|
|
|
303
306
|
runnerId: null
|
|
304
307
|
}
|
|
305
308
|
});
|
|
306
|
-
scheduleJob(job);
|
|
309
|
+
await scheduleJob(job);
|
|
307
310
|
return { status: 201, message: `Cron job "${taskName}" added` };
|
|
308
311
|
};
|
|
309
312
|
|
|
@@ -359,7 +362,7 @@ const updateCronJob = async (
|
|
|
359
362
|
}
|
|
360
363
|
});
|
|
361
364
|
|
|
362
|
-
scheduleJob(updated);
|
|
365
|
+
await scheduleJob(updated);
|
|
363
366
|
return { status: 200, message: 'Cron job updated' };
|
|
364
367
|
};
|
|
365
368
|
|
|
@@ -379,7 +382,7 @@ const toggleCronJob = async (taskName, enabled) => {
|
|
|
379
382
|
data: { enabled }
|
|
380
383
|
});
|
|
381
384
|
|
|
382
|
-
scheduleJob(updated); // re-schedules if enabled, stops and removes if disabled
|
|
385
|
+
await scheduleJob(updated); // re-schedules if enabled, stops and removes if disabled
|
|
383
386
|
return { status: 200, enabled: updated.enabled };
|
|
384
387
|
};
|
|
385
388
|
|
|
@@ -100,7 +100,9 @@ function collectTestFiles() {
|
|
|
100
100
|
if (entry.isDirectory()) {
|
|
101
101
|
walk(fullPath, relPath);
|
|
102
102
|
} else {
|
|
103
|
-
|
|
103
|
+
// base64, not utf8 — utf8 mangles non-text fixtures (e.g. upload test images)
|
|
104
|
+
// because arbitrary binary bytes aren't valid UTF-8 and get replaced on read.
|
|
105
|
+
files[relPath] = fs.readFileSync(fullPath).toString('base64');
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
}
|
|
@@ -26,12 +26,26 @@ const getProject = async () => {
|
|
|
26
26
|
return project;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const updateProject = async ({ name, logoUrl }) => {
|
|
30
|
-
|
|
29
|
+
const updateProject = async ({ name, logoUrl, timezone }) => {
|
|
30
|
+
const data = {
|
|
31
|
+
name: name ?? '',
|
|
32
|
+
logoUrl: logoUrl ?? '',
|
|
33
|
+
...(timezone !== undefined && { timezone })
|
|
34
|
+
};
|
|
35
|
+
const project = await prisma.project.upsert({
|
|
31
36
|
where: { id: 1 },
|
|
32
|
-
create: { id: 1,
|
|
33
|
-
update:
|
|
37
|
+
create: { id: 1, ...data },
|
|
38
|
+
update: data
|
|
34
39
|
});
|
|
40
|
+
|
|
41
|
+
if (timezone !== undefined) {
|
|
42
|
+
// Cron jobs read the timezone at schedule time, so a change here must
|
|
43
|
+
// re-schedule everything for the new offset to take effect immediately.
|
|
44
|
+
await require('./cronService').reload();
|
|
45
|
+
await require('./backupCronService').reload();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return project;
|
|
35
49
|
};
|
|
36
50
|
|
|
37
51
|
const getTestPrefixes = async () => {
|
|
@@ -24,15 +24,15 @@ function authHeaders() {
|
|
|
24
24
|
|
|
25
25
|
export async function fetchProject() {
|
|
26
26
|
const res = await fetch(`${API_BASE}/settings/project`);
|
|
27
|
-
if (!res.ok) return { name: '', logoUrl: '' };
|
|
27
|
+
if (!res.ok) return { name: '', logoUrl: '', timezone: 'UTC' };
|
|
28
28
|
return res.json();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function saveProject({ name, logoUrl }) {
|
|
31
|
+
export async function saveProject({ name, logoUrl, timezone }) {
|
|
32
32
|
const res = await fetch(`${API_BASE}/settings/project`, {
|
|
33
33
|
method: 'POST',
|
|
34
34
|
headers: { 'Content-Type': 'application/json' },
|
|
35
|
-
body: JSON.stringify({ name, logoUrl })
|
|
35
|
+
body: JSON.stringify({ name, logoUrl, timezone })
|
|
36
36
|
});
|
|
37
37
|
return res.json();
|
|
38
38
|
}
|
|
@@ -42,6 +42,7 @@ export const WORKERS_MIN = 1;
|
|
|
42
42
|
export const WORKERS_MAX = 10;
|
|
43
43
|
export const RUN_PICKER_LIMIT = 200;
|
|
44
44
|
export const RUN_TAG_DISPLAY_LIMIT = 5;
|
|
45
|
+
export const CASE_HISTORY_BARS_MAX = 20;
|
|
45
46
|
|
|
46
47
|
export const BUILTIN_RUNNER_ID = 'built-in';
|
|
47
48
|
export const BUILTIN_RUNNER_LABEL = 'Built-in';
|
|
@@ -68,10 +68,18 @@
|
|
|
68
68
|
} catch {}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
let project = { name: '', logoUrl: '' };
|
|
71
|
+
let project = { name: '', logoUrl: '', timezone: 'UTC' };
|
|
72
72
|
let projectSaving = false;
|
|
73
73
|
let toast = null;
|
|
74
74
|
|
|
75
|
+
const TIMEZONES = (() => {
|
|
76
|
+
try {
|
|
77
|
+
return Intl.supportedValuesOf('timeZone');
|
|
78
|
+
} catch {
|
|
79
|
+
return ['UTC'];
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
|
|
75
83
|
let prefixes = { testCasePrefix: 'TC', testSuitePrefix: 'TS' };
|
|
76
84
|
let prefixesSaving = false;
|
|
77
85
|
let migrateForm = { testCasePrefix: '', testSuitePrefix: '' };
|
|
@@ -679,6 +687,18 @@
|
|
|
679
687
|
</div>
|
|
680
688
|
{/if}
|
|
681
689
|
|
|
690
|
+
<div class="field">
|
|
691
|
+
<label class="field-label" for="project-timezone">
|
|
692
|
+
<span>Timezone</span>
|
|
693
|
+
<span class="field-hint">Used to schedule cron jobs and backups</span>
|
|
694
|
+
</label>
|
|
695
|
+
<select id="project-timezone" class="field-input" bind:value={project.timezone}>
|
|
696
|
+
{#each TIMEZONES as tz}
|
|
697
|
+
<option value={tz}>{tz}</option>
|
|
698
|
+
{/each}
|
|
699
|
+
</select>
|
|
700
|
+
</div>
|
|
701
|
+
|
|
682
702
|
<!-- Dark mode toggle -->
|
|
683
703
|
<div class="toggle-row">
|
|
684
704
|
<div class="toggle-info">
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
35
35
|
import Button from '$lib/components/ui/Button.svelte';
|
|
36
36
|
import Pagination from '$lib/components/ui/Pagination.svelte';
|
|
37
|
-
import { TOAST_TIMEOUT_MS, SUITE_CASES_PER_PAGE } from '$lib/constants';
|
|
37
|
+
import { TOAST_TIMEOUT_MS, SUITE_CASES_PER_PAGE, CASE_HISTORY_BARS_MAX } from '$lib/constants';
|
|
38
38
|
|
|
39
39
|
const suiteId = $page.params.id;
|
|
40
40
|
|
|
@@ -257,6 +257,12 @@
|
|
|
257
257
|
if (r === 'blocked') return 'warn';
|
|
258
258
|
return 'muted';
|
|
259
259
|
}
|
|
260
|
+
|
|
261
|
+
// selectedCase.history arrives newest-first; bars read oldest → newest, left → right.
|
|
262
|
+
function recentHistory(history) {
|
|
263
|
+
if (!history || history.length === 0) return [];
|
|
264
|
+
return history.slice(0, CASE_HISTORY_BARS_MAX).slice().reverse();
|
|
265
|
+
}
|
|
260
266
|
</script>
|
|
261
267
|
|
|
262
268
|
<svelte:head
|
|
@@ -623,6 +629,16 @@
|
|
|
623
629
|
<p class="detail-case-desc">{selectedCase.description}</p>
|
|
624
630
|
{/if}
|
|
625
631
|
<p class="detail-meta">Created by {selectedCase.createdBy.name}</p>
|
|
632
|
+
{#if selectedCase.history && selectedCase.history.length > 0}
|
|
633
|
+
<div class="case-history-bars">
|
|
634
|
+
{#each recentHistory(selectedCase.history) as h (h.id)}
|
|
635
|
+
<span
|
|
636
|
+
class="history-bar {resultClass(h.result)}"
|
|
637
|
+
title="{h.result} — {new Date(h.executedAt).toLocaleString()}"
|
|
638
|
+
></span>
|
|
639
|
+
{/each}
|
|
640
|
+
</div>
|
|
641
|
+
{/if}
|
|
626
642
|
{/if}
|
|
627
643
|
</div>
|
|
628
644
|
|
|
@@ -1112,6 +1128,37 @@
|
|
|
1112
1128
|
color: var(--text-muted);
|
|
1113
1129
|
}
|
|
1114
1130
|
|
|
1131
|
+
.case-history-bars {
|
|
1132
|
+
display: flex;
|
|
1133
|
+
align-items: flex-end;
|
|
1134
|
+
gap: 2px;
|
|
1135
|
+
height: 16px;
|
|
1136
|
+
margin-top: 0.5rem;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.history-bar {
|
|
1140
|
+
width: 4px;
|
|
1141
|
+
height: 100%;
|
|
1142
|
+
border-radius: 1px;
|
|
1143
|
+
background: var(--border);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
.history-bar.pass {
|
|
1147
|
+
background: var(--pass);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
.history-bar.fail {
|
|
1151
|
+
background: var(--fail);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.history-bar.warn {
|
|
1155
|
+
background: var(--warn);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.history-bar.muted {
|
|
1159
|
+
background: var(--text-muted);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1115
1162
|
.detail-tabs {
|
|
1116
1163
|
display: flex;
|
|
1117
1164
|
border-bottom: 1px solid var(--border);
|