plum-e2e 1.1.1 → 1.2.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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +27 -25
  2. package/.husky/pre-commit +2 -2
  3. package/README.md +142 -70
  4. package/backend/Dockerfile +4 -2
  5. package/backend/app.js +4 -2
  6. package/backend/config/scripts/generate-report.js +38 -30
  7. package/backend/entrypoint.sh +22 -0
  8. package/backend/package-lock.json +453 -10
  9. package/backend/package.json +5 -2
  10. package/backend/prisma/migrations/20260614000000_init/migration.sql +35 -0
  11. package/backend/prisma/migrations/20260614000001_add_project/migration.sql +8 -0
  12. package/backend/prisma/migrations/migration_lock.toml +3 -0
  13. package/backend/prisma/schema.prisma +53 -0
  14. package/backend/routes/backup.routes.js +50 -0
  15. package/backend/routes/cron.routes.js +9 -60
  16. package/backend/routes/reports.routes.js +39 -6
  17. package/backend/routes/settings.routes.js +43 -0
  18. package/backend/server.js +52 -1
  19. package/backend/services/backupService.js +88 -0
  20. package/backend/services/cronService.js +68 -78
  21. package/backend/services/{scheduleService.js → prisma.js} +3 -15
  22. package/backend/services/reportService.js +48 -20
  23. package/backend/{routes/schedules.routes.js → services/settingsService.js} +17 -13
  24. package/bin/plum.js +213 -32
  25. package/docker-compose.yml +24 -0
  26. package/frontend/package-lock.json +2 -2
  27. package/frontend/package.json +1 -1
  28. package/frontend/src/lib/api/reports.js +38 -27
  29. package/frontend/src/lib/api/schedules.js +9 -25
  30. package/frontend/src/lib/api/settings.js +48 -0
  31. package/frontend/src/lib/components/layout/Nav.svelte +2 -1
  32. package/frontend/src/lib/components/layout/RunnerPanel.svelte +160 -21
  33. package/frontend/src/lib/components/ui/Terminal.svelte +2 -2
  34. package/frontend/src/lib/stores/runner.js +9 -0
  35. package/frontend/src/routes/+page.svelte +10 -3
  36. package/frontend/src/routes/reports/+page.svelte +342 -51
  37. package/frontend/src/routes/reports/[slug]/+page.svelte +2 -0
  38. package/frontend/src/routes/scheduled-tests/+page.svelte +247 -11
  39. package/frontend/src/routes/settings/+page.svelte +410 -0
  40. package/license-config.json +2 -2
  41. package/package.json +6 -2
  42. package/backend/config/scripts/create-settings.js +0 -53
@@ -17,21 +17,29 @@
17
17
 
18
18
  <script>
19
19
  import { onMount, tick } from 'svelte';
20
- import { fetchReports } from '$lib/api/reports';
20
+ import { fetchReports, deleteReport, deleteReports } from '$lib/api/reports';
21
+ import { reportsVersion } from '$lib/stores/runner';
21
22
  import Badge from '$lib/components/ui/Badge.svelte';
22
23
  import Pagination from '$lib/components/ui/Pagination.svelte';
24
+ import Modal from '$lib/components/ui/Modal.svelte';
23
25
 
24
26
  let reports = [];
25
27
  let currentPage = 1;
26
28
  const PER_PAGE = 15;
27
29
  let animateBar = false;
28
30
 
31
+ let selected = new Set();
32
+ let deleteModal = { open: false, targets: [] };
33
+ let deleting = false;
34
+
29
35
  $: totalPages = Math.ceil(reports.length / PER_PAGE);
30
36
  $: paginated = reports.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE);
31
37
  $: passCount = reports.filter((r) => r.status === 'PASS').length;
32
38
  $: failCount = reports.length - passCount;
33
39
  $: passRate = reports.length ? Math.round((passCount / reports.length) * 100) : 0;
34
40
  $: trend = reports.slice(0, 12).reverse();
41
+ $: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.fileName));
42
+ $: someSelected = selected.size > 0;
35
43
 
36
44
  function triggerLabel(type) {
37
45
  if (type === 'manual-trigger') return 'Manual';
@@ -45,17 +53,95 @@
45
53
  return 'schedule';
46
54
  }
47
55
 
48
- onMount(async () => {
56
+ async function loadReports() {
49
57
  try {
50
58
  reports = await fetchReports();
59
+ selected = new Set();
51
60
  await tick();
52
61
  animateBar = true;
53
62
  } catch (e) {
54
63
  console.error('Failed to fetch reports', e);
55
64
  }
56
- });
65
+ }
66
+
67
+ onMount(loadReports);
68
+ $: if ($reportsVersion) loadReports();
69
+
70
+ function toggleSelect(fileName, e) {
71
+ e.preventDefault();
72
+ e.stopPropagation();
73
+ const next = new Set(selected);
74
+ if (next.has(fileName)) next.delete(fileName);
75
+ else next.add(fileName);
76
+ selected = next;
77
+ }
78
+
79
+ function toggleAll(e) {
80
+ e.stopPropagation();
81
+ if (allOnPageSelected) {
82
+ const next = new Set(selected);
83
+ paginated.forEach((r) => next.delete(r.fileName));
84
+ selected = next;
85
+ } else {
86
+ const next = new Set(selected);
87
+ paginated.forEach((r) => next.add(r.fileName));
88
+ selected = next;
89
+ }
90
+ }
91
+
92
+ function openDeleteModal(targets) {
93
+ deleteModal = { open: true, targets };
94
+ }
95
+
96
+ function openSingleDelete(fileName, e) {
97
+ e.preventDefault();
98
+ e.stopPropagation();
99
+ openDeleteModal([fileName]);
100
+ }
101
+
102
+ async function confirmDelete() {
103
+ deleting = true;
104
+ try {
105
+ if (deleteModal.targets.length === 1) {
106
+ await deleteReport(deleteModal.targets[0]);
107
+ } else {
108
+ await deleteReports(deleteModal.targets);
109
+ }
110
+ deleteModal = { open: false, targets: [] };
111
+ await loadReports();
112
+ } catch (e) {
113
+ console.error('Delete failed', e);
114
+ } finally {
115
+ deleting = false;
116
+ }
117
+ }
57
118
  </script>
58
119
 
120
+ <svelte:head><title>Reports — Plum</title></svelte:head>
121
+
122
+ <Modal
123
+ bind:open={deleteModal.open}
124
+ title={deleteModal.targets.length === 1
125
+ ? 'Delete report?'
126
+ : `Delete ${deleteModal.targets.length} reports?`}
127
+ >
128
+ <p class="modal-body">
129
+ {#if deleteModal.targets.length === 1}
130
+ This will permanently remove the report and its data file.
131
+ {:else}
132
+ This will permanently remove {deleteModal.targets.length} reports and their data files.
133
+ {/if}
134
+ </p>
135
+ <div class="modal-actions">
136
+ <button class="btn-cancel" on:click={() => (deleteModal.open = false)} disabled={deleting}>
137
+ Cancel
138
+ </button>
139
+ <button class="btn-danger" on:click={confirmDelete} disabled={deleting}>
140
+ {deleting ? 'Deleting…' : 'Delete'}
141
+ </button>
142
+ </div>
143
+ </Modal>
144
+
59
145
  <div class="page-header">
60
146
  <div class="header-top">
61
147
  <div>
@@ -65,19 +151,27 @@
65
151
  </p>
66
152
  </div>
67
153
 
68
- {#if reports.length > 0}
69
- <div class="rate-display">
70
- <span
71
- class="rate-number"
72
- class:pass={passRate >= 80}
73
- class:warn={passRate < 80 && passRate >= 50}
74
- class:fail={passRate < 50}
75
- >
76
- {passRate}%
77
- </span>
78
- <span class="rate-label">passing</span>
79
- </div>
80
- {/if}
154
+ <div class="header-actions">
155
+ {#if someSelected}
156
+ <button class="btn-delete-selected" on:click={() => openDeleteModal([...selected])}>
157
+ Delete ({selected.size})
158
+ </button>
159
+ {/if}
160
+
161
+ {#if reports.length > 0}
162
+ <div class="rate-display">
163
+ <span
164
+ class="rate-number"
165
+ class:pass={passRate >= 80}
166
+ class:warn={passRate < 80 && passRate >= 50}
167
+ class:fail={passRate < 50}
168
+ >
169
+ {passRate}%
170
+ </span>
171
+ <span class="rate-label">passing</span>
172
+ </div>
173
+ {/if}
174
+ </div>
81
175
  </div>
82
176
 
83
177
  {#if reports.length > 0}
@@ -112,39 +206,84 @@
112
206
  {#if reports.length === 0}
113
207
  <p class="empty">No reports yet. Run a test to generate one.</p>
114
208
  {:else}
209
+ <div class="list-header">
210
+ <label class="select-all-wrap" title="Select all on this page">
211
+ <input
212
+ type="checkbox"
213
+ class="checkbox"
214
+ checked={allOnPageSelected}
215
+ indeterminate={someSelected && !allOnPageSelected}
216
+ on:change={toggleAll}
217
+ />
218
+ </label>
219
+ </div>
220
+
115
221
  <div class="report-list">
116
222
  {#each paginated as report, i}
117
- <a
118
- class="report-item"
119
- class:is-pass={report.status === 'PASS'}
120
- class:is-fail={report.status !== 'PASS'}
121
- href="/reports/{encodeURIComponent(report.fileName)}"
223
+ <div
224
+ class="report-row"
225
+ class:is-selected={selected.has(report.fileName)}
122
226
  style="animation-delay: {i * 45}ms"
123
227
  >
124
- <div class="item-left">
125
- <span
126
- class="status-mark"
127
- class:pass={report.status === 'PASS'}
128
- class:fail={report.status !== 'PASS'}
129
- >
130
- {report.status === 'PASS' ? '✓' : '✗'}
131
- </span>
132
- <div class="item-meta">
133
- <span class="item-tags">{report.tags}</span>
134
- <div class="item-badges">
135
- <Badge variant={triggerVariant(report.triggerType)}>
136
- {triggerLabel(report.triggerType)}
137
- </Badge>
138
- <Badge variant="neutral">
139
- {report.runners} runner{report.runners !== 1 ? 's' : ''}
140
- </Badge>
228
+ <label class="row-check-wrap" title="Select">
229
+ <input
230
+ type="checkbox"
231
+ class="checkbox"
232
+ checked={selected.has(report.fileName)}
233
+ on:change={(e) => toggleSelect(report.fileName, e)}
234
+ />
235
+ </label>
236
+
237
+ <a
238
+ class="report-item"
239
+ class:is-pass={report.status === 'PASS'}
240
+ class:is-fail={report.status !== 'PASS'}
241
+ href="/reports/{encodeURIComponent(report.fileName)}"
242
+ >
243
+ <div class="item-left">
244
+ <span
245
+ class="status-mark"
246
+ class:pass={report.status === 'PASS'}
247
+ class:fail={report.status !== 'PASS'}
248
+ >
249
+ {report.status === 'PASS' ? '✓' : '✗'}
250
+ </span>
251
+ <div class="item-meta">
252
+ <span class="item-tags">{report.tags}</span>
253
+ <div class="item-badges">
254
+ <Badge variant={triggerVariant(report.triggerType)}>
255
+ {triggerLabel(report.triggerType)}
256
+ </Badge>
257
+ <Badge variant="neutral">
258
+ {report.runners} runner{report.runners !== 1 ? 's' : ''}
259
+ </Badge>
260
+ </div>
141
261
  </div>
142
262
  </div>
143
- </div>
144
- <div class="item-right">
145
- <span class="item-date">{report.date}</span>
263
+ <div class="item-right">
264
+ <span class="item-date">{report.date}</span>
265
+ <svg
266
+ class="item-arrow"
267
+ width="14"
268
+ height="14"
269
+ viewBox="0 0 24 24"
270
+ fill="none"
271
+ stroke="currentColor"
272
+ stroke-width="2"
273
+ stroke-linecap="round"
274
+ >
275
+ <line x1="5" y1="12" x2="19" y2="12" />
276
+ <polyline points="12 5 19 12 12 19" />
277
+ </svg>
278
+ </div>
279
+ </a>
280
+
281
+ <button
282
+ class="row-delete-btn"
283
+ title="Delete report"
284
+ on:click={(e) => openSingleDelete(report.fileName, e)}
285
+ >
146
286
  <svg
147
- class="item-arrow"
148
287
  width="14"
149
288
  height="14"
150
289
  viewBox="0 0 24 24"
@@ -152,12 +291,15 @@
152
291
  stroke="currentColor"
153
292
  stroke-width="2"
154
293
  stroke-linecap="round"
294
+ stroke-linejoin="round"
155
295
  >
156
- <line x1="5" y1="12" x2="19" y2="12" />
157
- <polyline points="12 5 19 12 12 19" />
296
+ <polyline points="3 6 5 6 21 6" />
297
+ <path d="M19 6l-1 14H6L5 6" />
298
+ <path d="M10 11v6M14 11v6" />
299
+ <path d="M9 6V4h6v2" />
158
300
  </svg>
159
- </div>
160
- </a>
301
+ </button>
302
+ </div>
161
303
  {/each}
162
304
  </div>
163
305
 
@@ -197,6 +339,33 @@
197
339
  font-size: 0.875rem;
198
340
  }
199
341
 
342
+ .header-actions {
343
+ display: flex;
344
+ align-items: center;
345
+ gap: 1rem;
346
+ }
347
+
348
+ .btn-delete-selected {
349
+ height: 32px;
350
+ padding: 0 0.875rem;
351
+ font-size: 0.8125rem;
352
+ font-family: inherit;
353
+ font-weight: 500;
354
+ color: var(--fail);
355
+ background: var(--fail-soft, rgba(239, 68, 68, 0.08));
356
+ border: 1px solid var(--fail);
357
+ border-radius: var(--radius-sm);
358
+ cursor: pointer;
359
+ transition:
360
+ background var(--duration-fast),
361
+ opacity var(--duration-fast);
362
+ }
363
+
364
+ .btn-delete-selected:hover {
365
+ background: var(--fail);
366
+ color: #fff;
367
+ }
368
+
200
369
  /* ── Pass rate ── */
201
370
  .rate-display {
202
371
  display: flex;
@@ -253,13 +422,11 @@
253
422
  display: flex;
254
423
  gap: 1rem;
255
424
  }
256
-
257
425
  .legend-pass {
258
426
  font-size: 0.75rem;
259
427
  color: var(--pass);
260
428
  font-weight: 500;
261
429
  }
262
-
263
430
  .legend-fail {
264
431
  font-size: 0.75rem;
265
432
  color: var(--fail);
@@ -299,7 +466,6 @@
299
466
  .trend-dot:hover {
300
467
  transform: scale(1.4);
301
468
  }
302
-
303
469
  .trend-dot.pass {
304
470
  background: var(--pass);
305
471
  }
@@ -313,14 +479,51 @@
313
479
  opacity: 0.6;
314
480
  }
315
481
 
316
- /* ── Report list ── */
482
+ /* ── Select all row ── */
483
+ .list-header {
484
+ display: flex;
485
+ align-items: center;
486
+ padding: 0 0.5rem 0.375rem;
487
+ }
488
+
489
+ .select-all-wrap {
490
+ display: flex;
491
+ align-items: center;
492
+ cursor: pointer;
493
+ padding: 0.25rem;
494
+ }
495
+
496
+ /* ── Report rows ── */
317
497
  .report-list {
318
498
  display: flex;
319
499
  flex-direction: column;
320
500
  gap: 0.5rem;
321
501
  }
322
502
 
503
+ .report-row {
504
+ display: flex;
505
+ align-items: center;
506
+ gap: 0.5rem;
507
+ animation: fadeUp 0.32s var(--ease-out) both;
508
+ }
509
+
510
+ .row-check-wrap {
511
+ flex-shrink: 0;
512
+ display: flex;
513
+ align-items: center;
514
+ cursor: pointer;
515
+ padding: 0.25rem;
516
+ }
517
+
518
+ .checkbox {
519
+ width: 15px;
520
+ height: 15px;
521
+ accent-color: var(--accent, #7c3aed);
522
+ cursor: pointer;
523
+ }
524
+
323
525
  .report-item {
526
+ flex: 1;
324
527
  display: flex;
325
528
  align-items: center;
326
529
  justify-content: space-between;
@@ -336,7 +539,7 @@
336
539
  background var(--duration-fast),
337
540
  transform var(--duration-fast) var(--ease-out),
338
541
  box-shadow var(--duration-fast);
339
- animation: fadeUp 0.32s var(--ease-out) both;
542
+ min-width: 0;
340
543
  }
341
544
 
342
545
  .report-item:hover {
@@ -352,6 +555,11 @@
352
555
  border-left-color: var(--fail);
353
556
  }
354
557
 
558
+ .report-row.is-selected .report-item {
559
+ background: var(--bg-subtle);
560
+ border-color: var(--accent, #7c3aed);
561
+ }
562
+
355
563
  .item-left {
356
564
  display: flex;
357
565
  align-items: center;
@@ -417,6 +625,89 @@
417
625
  color: var(--text);
418
626
  }
419
627
 
628
+ /* ── Per-row delete button ── */
629
+ .row-delete-btn {
630
+ flex-shrink: 0;
631
+ display: flex;
632
+ align-items: center;
633
+ justify-content: center;
634
+ width: 30px;
635
+ height: 30px;
636
+ border: none;
637
+ border-radius: var(--radius-sm);
638
+ background: transparent;
639
+ color: var(--text-muted);
640
+ cursor: pointer;
641
+ opacity: 0;
642
+ transition:
643
+ opacity var(--duration-fast),
644
+ color var(--duration-fast),
645
+ background var(--duration-fast);
646
+ }
647
+
648
+ .report-row:hover .row-delete-btn {
649
+ opacity: 1;
650
+ }
651
+
652
+ .row-delete-btn:hover {
653
+ color: var(--fail);
654
+ background: var(--fail-soft, rgba(239, 68, 68, 0.08));
655
+ }
656
+
657
+ /* ── Modal ── */
658
+ .modal-actions {
659
+ display: flex;
660
+ justify-content: flex-end;
661
+ gap: 0.5rem;
662
+ }
663
+
664
+ .modal-body {
665
+ font-size: 0.9rem;
666
+ color: var(--text-muted);
667
+ line-height: 1.6;
668
+ margin: 0;
669
+ }
670
+
671
+ .btn-cancel {
672
+ height: 34px;
673
+ padding: 0 1rem;
674
+ font-size: 0.8125rem;
675
+ font-family: inherit;
676
+ background: var(--bg-elevated);
677
+ border: 1px solid var(--border);
678
+ border-radius: var(--radius-sm);
679
+ cursor: pointer;
680
+ color: var(--text);
681
+ transition: background var(--duration-fast);
682
+ }
683
+
684
+ .btn-cancel:hover {
685
+ background: var(--bg-subtle);
686
+ }
687
+
688
+ .btn-danger {
689
+ height: 34px;
690
+ padding: 0 1rem;
691
+ font-size: 0.8125rem;
692
+ font-family: inherit;
693
+ font-weight: 500;
694
+ background: var(--fail);
695
+ border: 1px solid var(--fail);
696
+ border-radius: var(--radius-sm);
697
+ cursor: pointer;
698
+ color: #fff;
699
+ transition: opacity var(--duration-fast);
700
+ }
701
+
702
+ .btn-danger:hover:not(:disabled) {
703
+ opacity: 0.85;
704
+ }
705
+ .btn-danger:disabled {
706
+ opacity: 0.5;
707
+ cursor: not-allowed;
708
+ }
709
+
710
+ /* ── Misc ── */
420
711
  .pagination-wrap {
421
712
  margin-top: 1.25rem;
422
713
  }
@@ -123,6 +123,8 @@
123
123
  })) ?? [];
124
124
  </script>
125
125
 
126
+ <svelte:head><title>Report — Plum</title></svelte:head>
127
+
126
128
  <div class="back-row">
127
129
  <a href="/reports" class="back-link">
128
130
  <svg