plum-e2e 1.2.4 → 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.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +132 -19
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +1 -1
  62. package/frontend/static/favicon.png +0 -0
@@ -20,6 +20,9 @@
20
20
  import { slide } from 'svelte/transition';
21
21
  import { fetchSuites } from '$lib/api/tests';
22
22
  import { runnerConfig, triggerRun, testsVersion } from '$lib/stores/runner';
23
+ import { COPY_TIMEOUT_MS } from '$lib/constants';
24
+ import { stagger } from '$lib/utils/format';
25
+ import EmptyState from '$lib/components/ui/EmptyState.svelte';
23
26
 
24
27
  let suites = [];
25
28
  let search = '';
@@ -36,8 +39,6 @@
36
39
  }
37
40
 
38
41
  onMount(loadSuites);
39
-
40
- // Re-fetch when the backend notifies us that test files changed
41
42
  $: if ($testsVersion) loadSuites();
42
43
 
43
44
  function suiteIds(suite) {
@@ -53,11 +54,8 @@
53
54
  }
54
55
 
55
56
  function toggleSteps(id) {
56
- if (expandedSteps.has(id)) {
57
- expandedSteps.delete(id);
58
- } else {
59
- expandedSteps.add(id);
60
- }
57
+ if (expandedSteps.has(id)) expandedSteps.delete(id);
58
+ else expandedSteps.add(id);
61
59
  expandedSteps = expandedSteps;
62
60
  }
63
61
 
@@ -67,15 +65,13 @@
67
65
  }
68
66
 
69
67
  function runSuite(suite) {
70
- const id = suiteIds(suite)[0];
71
- run(id);
68
+ run(suiteIds(suite)[0]);
72
69
  }
73
70
 
74
71
  async function copyId(id) {
75
72
  await navigator.clipboard.writeText(id);
76
73
  copiedIds.add(id);
77
74
  copiedIds = copiedIds;
78
-
79
75
  if (copyTimers.has(id)) clearTimeout(copyTimers.get(id));
80
76
  copyTimers.set(
81
77
  id,
@@ -83,7 +79,7 @@
83
79
  copiedIds.delete(id);
84
80
  copiedIds = copiedIds;
85
81
  copyTimers.delete(id);
86
- }, 1400)
82
+ }, COPY_TIMEOUT_MS)
87
83
  );
88
84
  }
89
85
 
@@ -166,13 +162,11 @@
166
162
  </div>
167
163
 
168
164
  {#if filtered.length === 0}
169
- <p class="empty">
170
- {q ? `No tests matching "${search}"` : 'No test suites found.'}
171
- </p>
165
+ <EmptyState message={q ? `No tests matching "${search}"` : 'No test suites found.'} />
172
166
  {:else}
173
167
  <div class="suites">
174
168
  {#each filtered as suite, si}
175
- <div class="suite" style="animation-delay: {si * 55}ms">
169
+ <div class="suite" style={stagger(si, 55)}>
176
170
  <div class="suite-header">
177
171
  <div class="suite-meta">
178
172
  <div class="suite-badges">
@@ -205,7 +199,7 @@
205
199
  {#each suite.tests as test, ti}
206
200
  {@const pid = primaryId(test)}
207
201
  {@const stepsOpen = expandedSteps.has(pid)}
208
- <div class="test-row" style="animation-delay: {(si * 4 + ti) * 30}ms">
202
+ <div class="test-row" style={stagger(si * 4 + ti, 30)}>
209
203
  <div class="test-main">
210
204
  <button
211
205
  class="run-icon-btn"
@@ -276,19 +270,15 @@
276
270
  <div class="examples-table-wrap">
277
271
  <table class="examples-table">
278
272
  <thead>
279
- <tr>
280
- {#each test.examples.headers as h}
281
- <th>{h}</th>
282
- {/each}
283
- </tr>
273
+ <tr
274
+ >{#each test.examples.headers as h}<th>{h}</th>{/each}</tr
275
+ >
284
276
  </thead>
285
277
  <tbody>
286
278
  {#each test.examples.rows as row}
287
- <tr>
288
- {#each row as cell}
289
- <td>{cell}</td>
290
- {/each}
291
- </tr>
279
+ <tr
280
+ >{#each row as cell}<td>{cell}</td>{/each}</tr
281
+ >
292
282
  {/each}
293
283
  </tbody>
294
284
  </table>
@@ -557,7 +547,6 @@
557
547
  .id-pill:hover {
558
548
  filter: brightness(0.92);
559
549
  }
560
-
561
550
  .id-pill.copied {
562
551
  color: var(--pass);
563
552
  background: var(--pass-soft);
@@ -632,7 +621,6 @@
632
621
  .steps-toggle svg {
633
622
  transition: transform var(--duration-fast) var(--ease-out);
634
623
  }
635
-
636
624
  .steps-toggle svg.rotated {
637
625
  transform: rotate(90deg);
638
626
  }
@@ -718,13 +706,6 @@
718
706
  border-bottom: none;
719
707
  }
720
708
 
721
- .empty {
722
- color: var(--text-muted);
723
- font-size: 0.9375rem;
724
- padding: 3rem 0;
725
- text-align: center;
726
- }
727
-
728
709
  @keyframes copiedPop {
729
710
  0% {
730
711
  opacity: 0;
@@ -17,42 +17,35 @@
17
17
 
18
18
  <script>
19
19
  import { onMount, tick } from 'svelte';
20
- import { fetchReports, deleteReport, deleteReports } from '$lib/api/reports';
20
+ import { fetchReports, deleteReport, deleteReports, reportUrl } from '$lib/api/reports';
21
21
  import { reportsVersion } from '$lib/stores/runner';
22
+ import { REPORTS_PER_PAGE } from '$lib/constants';
23
+ import { isScheduled, triggerLabel, triggerVariant, stagger } from '$lib/utils/format';
22
24
  import Badge from '$lib/components/ui/Badge.svelte';
23
25
  import Pagination from '$lib/components/ui/Pagination.svelte';
24
- import Modal from '$lib/components/ui/Modal.svelte';
26
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
27
+ import EmptyState from '$lib/components/ui/EmptyState.svelte';
25
28
 
26
29
  let reports = [];
27
30
  let currentPage = 1;
28
- const PER_PAGE = 15;
29
31
  let animateBar = false;
30
32
 
31
33
  let selected = new Set();
32
34
  let deleteModal = { open: false, targets: [] };
33
35
  let deleting = false;
34
36
 
35
- $: totalPages = Math.ceil(reports.length / PER_PAGE);
36
- $: paginated = reports.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE);
37
+ $: totalPages = Math.ceil(reports.length / REPORTS_PER_PAGE);
38
+ $: paginated = reports.slice(
39
+ (currentPage - 1) * REPORTS_PER_PAGE,
40
+ currentPage * REPORTS_PER_PAGE
41
+ );
37
42
  $: passCount = reports.filter((r) => r.status === 'PASS').length;
38
43
  $: failCount = reports.length - passCount;
39
44
  $: passRate = reports.length ? Math.round((passCount / reports.length) * 100) : 0;
40
45
  $: trend = reports.slice(0, 12).reverse();
41
- $: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.fileName));
46
+ $: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.id));
42
47
  $: someSelected = selected.size > 0;
43
48
 
44
- function triggerLabel(type) {
45
- if (type === 'manual-trigger') return 'Manual';
46
- if (type === 'command-line-trigger' || type === 'undefined') return 'CLI';
47
- return 'Scheduled';
48
- }
49
-
50
- function triggerVariant(type) {
51
- if (type === 'manual-trigger') return 'tag';
52
- if (type === 'command-line-trigger' || type === 'undefined') return 'neutral';
53
- return 'schedule';
54
- }
55
-
56
49
  async function loadReports() {
57
50
  try {
58
51
  reports = await fetchReports();
@@ -67,12 +60,12 @@
67
60
  onMount(loadReports);
68
61
  $: if ($reportsVersion) loadReports();
69
62
 
70
- function toggleSelect(fileName, e) {
63
+ function toggleSelect(id, e) {
71
64
  e.preventDefault();
72
65
  e.stopPropagation();
73
66
  const next = new Set(selected);
74
- if (next.has(fileName)) next.delete(fileName);
75
- else next.add(fileName);
67
+ if (next.has(id)) next.delete(id);
68
+ else next.add(id);
76
69
  selected = next;
77
70
  }
78
71
 
@@ -80,11 +73,11 @@
80
73
  e.stopPropagation();
81
74
  if (allOnPageSelected) {
82
75
  const next = new Set(selected);
83
- paginated.forEach((r) => next.delete(r.fileName));
76
+ paginated.forEach((r) => next.delete(r.id));
84
77
  selected = next;
85
78
  } else {
86
79
  const next = new Set(selected);
87
- paginated.forEach((r) => next.add(r.fileName));
80
+ paginated.forEach((r) => next.add(r.id));
88
81
  selected = next;
89
82
  }
90
83
  }
@@ -93,10 +86,10 @@
93
86
  deleteModal = { open: true, targets };
94
87
  }
95
88
 
96
- function openSingleDelete(fileName, e) {
89
+ function openSingleDelete(id, e) {
97
90
  e.preventDefault();
98
91
  e.stopPropagation();
99
- openDeleteModal([fileName]);
92
+ openDeleteModal([id]);
100
93
  }
101
94
 
102
95
  async function confirmDelete() {
@@ -105,7 +98,7 @@
105
98
  if (deleteModal.targets.length === 1) {
106
99
  await deleteReport(deleteModal.targets[0]);
107
100
  } else {
108
- await deleteReports(deleteModal.targets);
101
+ await deleteReports([...deleteModal.targets]);
109
102
  }
110
103
  deleteModal = { open: false, targets: [] };
111
104
  await loadReports();
@@ -119,28 +112,21 @@
119
112
 
120
113
  <svelte:head><title>Reports — Plum</title></svelte:head>
121
114
 
122
- <Modal
115
+ <ConfirmModal
123
116
  bind:open={deleteModal.open}
124
117
  title={deleteModal.targets.length === 1
125
118
  ? 'Delete report?'
126
119
  : `Delete ${deleteModal.targets.length} reports?`}
120
+ confirmLabel="Delete"
121
+ loading={deleting}
122
+ on:confirm={confirmDelete}
127
123
  >
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>
124
+ {#if deleteModal.targets.length === 1}
125
+ This will permanently remove the report and its data file.
126
+ {:else}
127
+ This will permanently remove {deleteModal.targets.length} reports and their data files.
128
+ {/if}
129
+ </ConfirmModal>
144
130
 
145
131
  <div class="page-header">
146
132
  <div class="header-top">
@@ -151,27 +137,19 @@
151
137
  </p>
152
138
  </div>
153
139
 
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>
140
+ {#if reports.length > 0}
141
+ <div class="rate-display">
142
+ <span
143
+ class="rate-number"
144
+ class:pass={passRate >= 80}
145
+ class:warn={passRate < 80 && passRate >= 50}
146
+ class:fail={passRate < 50}
147
+ >
148
+ {passRate}%
149
+ </span>
150
+ <span class="rate-label">passing</span>
151
+ </div>
152
+ {/if}
175
153
  </div>
176
154
 
177
155
  {#if reports.length > 0}
@@ -193,7 +171,7 @@
193
171
  class="trend-dot"
194
172
  class:pass={r.status === 'PASS'}
195
173
  class:fail={r.status !== 'PASS'}
196
- style="animation-delay: {i * 35}ms"
174
+ style={stagger(i, 35)}
197
175
  title="{r.status} · {r.tags} · {r.date}"
198
176
  ></span>
199
177
  {/each}
@@ -204,7 +182,7 @@
204
182
  </div>
205
183
 
206
184
  {#if reports.length === 0}
207
- <p class="empty">No reports yet. Run a test to generate one.</p>
185
+ <EmptyState message="No reports yet. Run a test to generate one." />
208
186
  {:else}
209
187
  <div class="list-header">
210
188
  <label class="select-all-wrap" title="Select all on this page">
@@ -216,21 +194,22 @@
216
194
  on:change={toggleAll}
217
195
  />
218
196
  </label>
197
+ {#if someSelected}
198
+ <button class="btn-delete-selected" on:click={() => openDeleteModal([...selected])}>
199
+ Delete ({selected.size})
200
+ </button>
201
+ {/if}
219
202
  </div>
220
203
 
221
204
  <div class="report-list">
222
205
  {#each paginated as report, i}
223
- <div
224
- class="report-row"
225
- class:is-selected={selected.has(report.fileName)}
226
- style="animation-delay: {i * 45}ms"
227
- >
206
+ <div class="report-row" class:is-selected={selected.has(report.id)} style={stagger(i)}>
228
207
  <label class="row-check-wrap" title="Select">
229
208
  <input
230
209
  type="checkbox"
231
210
  class="checkbox"
232
- checked={selected.has(report.fileName)}
233
- on:change={(e) => toggleSelect(report.fileName, e)}
211
+ checked={selected.has(report.id)}
212
+ on:change={(e) => toggleSelect(report.id, e)}
234
213
  />
235
214
  </label>
236
215
 
@@ -238,7 +217,7 @@
238
217
  class="report-item"
239
218
  class:is-pass={report.status === 'PASS'}
240
219
  class:is-fail={report.status !== 'PASS'}
241
- href="/reports/{encodeURIComponent(report.fileName)}"
220
+ href={reportUrl(report.id)}
242
221
  >
243
222
  <div class="item-left">
244
223
  <span
@@ -249,14 +228,16 @@
249
228
  {report.status === 'PASS' ? '✓' : '✗'}
250
229
  </span>
251
230
  <div class="item-meta">
252
- <span class="item-tags">{report.tags}</span>
231
+ <span class="item-tags"
232
+ >{isScheduled(report.triggerType) ? report.triggerType : report.tags}</span
233
+ >
253
234
  <div class="item-badges">
254
235
  <Badge variant={triggerVariant(report.triggerType)}>
255
236
  {triggerLabel(report.triggerType)}
256
237
  </Badge>
257
- <Badge variant="neutral">
258
- {report.runners} runner{report.runners !== 1 ? 's' : ''}
259
- </Badge>
238
+ {#if report.browser && report.browser !== 'chromium'}
239
+ <Badge variant="neutral">{report.browser}</Badge>
240
+ {/if}
260
241
  </div>
261
242
  </div>
262
243
  </div>
@@ -281,7 +262,7 @@
281
262
  <button
282
263
  class="row-delete-btn"
283
264
  title="Delete report"
284
- on:click={(e) => openSingleDelete(report.fileName, e)}
265
+ on:click={(e) => openSingleDelete(report.id, e)}
285
266
  >
286
267
  <svg
287
268
  width="14"
@@ -339,33 +320,6 @@
339
320
  font-size: 0.875rem;
340
321
  }
341
322
 
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
-
369
323
  /* ── Pass rate ── */
370
324
  .rate-display {
371
325
  display: flex;
@@ -422,6 +376,7 @@
422
376
  display: flex;
423
377
  gap: 1rem;
424
378
  }
379
+
425
380
  .legend-pass {
426
381
  font-size: 0.75rem;
427
382
  color: var(--pass);
@@ -479,10 +434,11 @@
479
434
  opacity: 0.6;
480
435
  }
481
436
 
482
- /* ── Select all row ── */
437
+ /* ── Select-all row ── */
483
438
  .list-header {
484
439
  display: flex;
485
440
  align-items: center;
441
+ justify-content: space-between;
486
442
  padding: 0 0.5rem 0.375rem;
487
443
  }
488
444
 
@@ -493,6 +449,27 @@
493
449
  padding: 0.25rem;
494
450
  }
495
451
 
452
+ .btn-delete-selected {
453
+ height: 30px;
454
+ padding: 0 0.75rem;
455
+ font-size: 0.78rem;
456
+ font-family: inherit;
457
+ font-weight: 500;
458
+ color: var(--fail);
459
+ background: var(--fail-soft, rgba(239, 68, 68, 0.08));
460
+ border: 1px solid var(--fail);
461
+ border-radius: var(--radius-sm);
462
+ cursor: pointer;
463
+ transition:
464
+ background var(--duration-fast),
465
+ opacity var(--duration-fast);
466
+ }
467
+
468
+ .btn-delete-selected:hover {
469
+ background: var(--fail);
470
+ color: #fff;
471
+ }
472
+
496
473
  /* ── Report rows ── */
497
474
  .report-list {
498
475
  display: flex;
@@ -654,68 +631,8 @@
654
631
  background: var(--fail-soft, rgba(239, 68, 68, 0.08));
655
632
  }
656
633
 
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
634
  /* ── Misc ── */
711
635
  .pagination-wrap {
712
636
  margin-top: 1.25rem;
713
637
  }
714
-
715
- .empty {
716
- color: var(--text-muted);
717
- font-size: 0.9375rem;
718
- padding: 3rem 0;
719
- text-align: center;
720
- }
721
638
  </style>