plum-e2e 1.1.0 → 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 +44 -16
  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
@@ -18,17 +18,27 @@
18
18
  <script>
19
19
  import { onMount } from 'svelte';
20
20
  import { fly } from 'svelte/transition';
21
- import { fetchSchedules, fetchCronJobs, saveCronJob, deleteCronJob } from '$lib/api/schedules';
21
+ import { fetchCronJobs, saveCronJob, deleteCronJob } from '$lib/api/schedules';
22
+ import { activeCronJobs } from '$lib/stores/runner';
22
23
  import Button from '$lib/components/ui/Button.svelte';
23
24
  import Badge from '$lib/components/ui/Badge.svelte';
24
25
  import Modal from '$lib/components/ui/Modal.svelte';
25
26
 
26
27
  const CRON_REGEX =
27
- /^(\*|([0-5]?[0-9])) (\*|([01]?[0-9]|2[0-3])) (\*|([01]?[0-9]|3[01])) (\*|([1-9]|1[0-2])) (\*|[0-6])$/;
28
+ /^(\*|([0-5]?[0-9])|\*\/[0-9]+) (\*|([01]?[0-9]|2[0-3])) (\*|([01]?[0-9]|3[01])) (\*|([1-9]|1[0-2])) (\*|[0-6](-[0-6])?)$/;
29
+
30
+ const CUSTOM_SENTINEL = '__custom__';
31
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
32
+
33
+ const scheduleOptions = [
34
+ { label: 'Every minute', value: '* * * * *' },
35
+ { label: 'Every hour', value: '0 * * * *' },
36
+ { label: 'Every midnight', value: '0 0 * * *' },
37
+ { label: 'Every Sunday', value: '0 0 * * 0' }
38
+ ];
28
39
 
29
40
  let cronJobs = [];
30
- let scheduleOptions = [];
31
- let toast = null; // { type: 'success' | 'error', message }
41
+ let toast = null;
32
42
 
33
43
  let modalOpen = false;
34
44
  let deleteModalOpen = false;
@@ -36,18 +46,68 @@
36
46
  let editTaskName = '';
37
47
  let taskToDelete = '';
38
48
 
39
- let form = { taskName: '', cronExpression: '', tags: '' };
49
+ const WORKER_OPTIONS = [1, 2, 4, 8];
50
+
51
+ let form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
52
+ let selectedSchedule = '';
53
+ let useCustomCron = false;
40
54
  let formError = '';
41
55
 
56
+ function describeCron(expr) {
57
+ if (!expr) return '';
58
+ const parts = expr.trim().split(/\s+/);
59
+ if (parts.length !== 5) return '';
60
+ const [min, hour, dom, month, dow] = parts;
61
+
62
+ const fmt = (h, m) => {
63
+ const ap = h >= 12 ? 'PM' : 'AM';
64
+ return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${ap}`;
65
+ };
66
+ const isNum = (s) => /^\d+$/.test(s);
67
+ const h = isNum(hour) ? +hour : null;
68
+ const m = isNum(min) ? +min : null;
69
+
70
+ if (expr === '* * * * *') return 'Every minute';
71
+ const everyN = min.match(/^\*\/(\d+)$/);
72
+ if (everyN && hour === '*' && dom === '*' && month === '*' && dow === '*')
73
+ return `Every ${everyN[1]} minutes`;
74
+ if (m !== null && hour === '*' && dom === '*' && month === '*' && dow === '*')
75
+ return `Every hour at :${String(m).padStart(2, '0')}`;
76
+ if (m !== null && h !== null && dom === '*' && month === '*' && dow === '1-5')
77
+ return `Weekdays at ${fmt(h, m)}`;
78
+ if (m !== null && h !== null && dom === '*' && month === '*' && dow === '*')
79
+ return `Daily at ${fmt(h, m)}`;
80
+ if (m !== null && h !== null && dom === '*' && month === '*' && /^\d$/.test(dow))
81
+ return `Every ${DAYS[+dow]} at ${fmt(h, m)}`;
82
+ const d = isNum(dom) ? +dom : null;
83
+ if (m !== null && h !== null && d !== null && month === '*' && dow === '*') {
84
+ const sfx = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th';
85
+ return `Monthly on the ${d}${sfx} at ${fmt(h, m)}`;
86
+ }
87
+ return '';
88
+ }
89
+
42
90
  function showToast(type, message) {
43
91
  toast = { type, message };
44
92
  setTimeout(() => (toast = null), 4000);
45
93
  }
46
94
 
95
+ function handleScheduleChange(e) {
96
+ if (e.target.value === CUSTOM_SENTINEL) {
97
+ useCustomCron = true;
98
+ form.cronExpression = '';
99
+ } else {
100
+ useCustomCron = false;
101
+ form.cronExpression = e.target.value;
102
+ }
103
+ }
104
+
47
105
  function openAddModal() {
48
106
  isEditing = false;
49
107
  editTaskName = '';
50
- form = { taskName: '', cronExpression: '', tags: '' };
108
+ form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
109
+ selectedSchedule = '';
110
+ useCustomCron = false;
51
111
  formError = '';
52
112
  modalOpen = true;
53
113
  }
@@ -55,7 +115,15 @@
55
115
  function openEditModal(job) {
56
116
  isEditing = true;
57
117
  editTaskName = job.taskName;
58
- form = { taskName: job.taskName, cronExpression: job.cronExpression, tags: job.tags };
118
+ form = {
119
+ taskName: job.taskName,
120
+ cronExpression: job.cronExpression,
121
+ tags: job.tags,
122
+ workers: job.workers ?? 1
123
+ };
124
+ const isPreset = scheduleOptions.some((o) => o.value === job.cronExpression);
125
+ useCustomCron = !isPreset;
126
+ selectedSchedule = isPreset ? job.cronExpression : CUSTOM_SENTINEL;
59
127
  formError = '';
60
128
  modalOpen = true;
61
129
  }
@@ -110,10 +178,12 @@
110
178
  }
111
179
 
112
180
  onMount(async () => {
113
- [cronJobs, scheduleOptions] = await Promise.all([fetchCronJobs(), fetchSchedules()]);
181
+ cronJobs = await fetchCronJobs();
114
182
  });
115
183
  </script>
116
184
 
185
+ <svelte:head><title>Scheduled Tests — Plum</title></svelte:head>
186
+
117
187
  <!-- Add/Edit modal -->
118
188
  <Modal bind:open={modalOpen} title={isEditing ? 'Edit Cron Job' : 'New Cron Job'}>
119
189
  <form on:submit|preventDefault={handleSave} class="modal-form">
@@ -138,12 +208,35 @@
138
208
  <div class="field-label">
139
209
  <span>Schedule</span>
140
210
  </div>
141
- <select class="field-input" bind:value={form.cronExpression} required>
142
- <option value="" disabled selected>Select a schedule</option>
211
+ <select
212
+ class="field-input"
213
+ bind:value={selectedSchedule}
214
+ on:change={handleScheduleChange}
215
+ required
216
+ >
217
+ <option value="" disabled>Select a schedule</option>
143
218
  {#each scheduleOptions as opt}
144
219
  <option value={opt.value}>{opt.label}</option>
145
220
  {/each}
221
+ <option value={CUSTOM_SENTINEL}>Custom…</option>
146
222
  </select>
223
+
224
+ {#if useCustomCron}
225
+ <input
226
+ type="text"
227
+ class="field-input cron-input"
228
+ bind:value={form.cronExpression}
229
+ placeholder="0 9 * * 1-5"
230
+ spellcheck="false"
231
+ />
232
+ {#if form.cronExpression}
233
+ <p class="cron-desc" class:cron-invalid={!CRON_REGEX.test(form.cronExpression)}>
234
+ {CRON_REGEX.test(form.cronExpression)
235
+ ? describeCron(form.cronExpression) || form.cronExpression
236
+ : 'Invalid cron expression'}
237
+ </p>
238
+ {/if}
239
+ {/if}
147
240
  </div>
148
241
 
149
242
  <div class="field">
@@ -160,6 +253,26 @@
160
253
  />
161
254
  </div>
162
255
 
256
+ <div class="field">
257
+ <div class="field-label">
258
+ <span>Runners</span>
259
+ <span class="field-hint">Parallel workers for this job</span>
260
+ </div>
261
+ <div class="seg-control">
262
+ {#each WORKER_OPTIONS as n}
263
+ <button
264
+ type="button"
265
+ class="seg-btn"
266
+ class:active={form.workers === n}
267
+ on:click={() => (form.workers = n)}
268
+ >
269
+ <span class="seg-num">{n}</span>
270
+ <span class="seg-label">{n === 1 ? 'runner' : 'runners'}</span>
271
+ </button>
272
+ {/each}
273
+ </div>
274
+ </div>
275
+
163
276
  {#if formError}
164
277
  <p class="form-error">{formError}</p>
165
278
  {/if}
@@ -209,19 +322,30 @@
209
322
  <th>Name</th>
210
323
  <th>Schedule</th>
211
324
  <th>Tags</th>
325
+ <th>Runners</th>
212
326
  <th>Actions</th>
213
327
  </tr>
214
328
  </thead>
215
329
  <tbody>
216
330
  {#each cronJobs as job, i}
217
331
  <tr style="animation-delay: {i * 45}ms" class="job-row">
218
- <td class="job-name">{job.taskName}</td>
332
+ <td class="job-name">
333
+ {#if $activeCronJobs[job.taskName]}
334
+ <span class="running-dot" title="Running now"></span>
335
+ {/if}
336
+ {job.taskName}
337
+ </td>
219
338
  <td>
220
339
  <Badge variant="schedule">{cronLabel(job.cronExpression)}</Badge>
221
340
  </td>
222
341
  <td>
223
342
  <span class="tag-text">{job.tags}</span>
224
343
  </td>
344
+ <td>
345
+ <span class="workers-badge" class:multi={job.workers > 1}>
346
+ ×{job.workers}
347
+ </span>
348
+ </td>
225
349
  <td class="actions-cell">
226
350
  <Button variant="ghost" size="sm" on:click={() => openEditModal(job)}>Edit</Button>
227
351
  <Button variant="danger" size="sm" on:click={() => openDeleteModal(job.taskName)}
@@ -276,6 +400,31 @@
276
400
  .job-name {
277
401
  font-weight: 400;
278
402
  font-size: 0.875rem;
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 0.5rem;
406
+ }
407
+
408
+ .running-dot {
409
+ display: inline-block;
410
+ width: 7px;
411
+ height: 7px;
412
+ border-radius: 50%;
413
+ background: var(--pass);
414
+ flex-shrink: 0;
415
+ animation: statusPulse 1.6s ease-in-out infinite;
416
+ }
417
+
418
+ @keyframes statusPulse {
419
+ 0%,
420
+ 100% {
421
+ opacity: 1;
422
+ transform: scale(1);
423
+ }
424
+ 50% {
425
+ opacity: 0.4;
426
+ transform: scale(0.65);
427
+ }
279
428
  }
280
429
 
281
430
  .tag-text {
@@ -324,4 +473,91 @@
324
473
  gap: 0.625rem;
325
474
  padding-top: 0.25rem;
326
475
  }
476
+
477
+ /* Custom cron input */
478
+ .cron-input {
479
+ margin-top: 0.5rem;
480
+ font-family: 'JetBrains Mono', monospace;
481
+ font-size: 0.875rem;
482
+ letter-spacing: 0.03em;
483
+ }
484
+
485
+ .cron-desc {
486
+ margin-top: 0.375rem;
487
+ font-size: 0.8rem;
488
+ color: var(--pass);
489
+ }
490
+
491
+ .cron-desc.cron-invalid {
492
+ color: var(--fail);
493
+ }
494
+
495
+ /* Segmented control */
496
+ .seg-control {
497
+ display: flex;
498
+ border: 1px solid var(--border);
499
+ border-radius: var(--radius-sm);
500
+ overflow: hidden;
501
+ width: fit-content;
502
+ }
503
+
504
+ .seg-btn {
505
+ display: flex;
506
+ flex-direction: column;
507
+ align-items: center;
508
+ padding: 0.375rem 0.875rem;
509
+ font-family: inherit;
510
+ background: var(--bg-elevated);
511
+ color: var(--text-muted);
512
+ border: none;
513
+ border-right: 1px solid var(--border);
514
+ cursor: pointer;
515
+ transition:
516
+ background var(--duration-fast),
517
+ color var(--duration-fast);
518
+ }
519
+
520
+ .seg-num {
521
+ font-size: 0.875rem;
522
+ font-weight: 600;
523
+ line-height: 1.2;
524
+ }
525
+
526
+ .seg-label {
527
+ font-size: 0.625rem;
528
+ letter-spacing: 0.04em;
529
+ text-transform: uppercase;
530
+ opacity: 0.75;
531
+ line-height: 1.2;
532
+ }
533
+
534
+ .seg-btn:last-child {
535
+ border-right: none;
536
+ }
537
+
538
+ .seg-btn:hover {
539
+ background: var(--bg-subtle);
540
+ color: var(--text);
541
+ }
542
+
543
+ .seg-btn.active {
544
+ background: var(--accent);
545
+ color: #fff;
546
+ }
547
+
548
+ .seg-btn.active .seg-label {
549
+ opacity: 0.85;
550
+ }
551
+
552
+ /* Workers badge in table */
553
+ .workers-badge {
554
+ font-family: 'JetBrains Mono', monospace;
555
+ font-size: 0.75rem;
556
+ color: var(--text-muted);
557
+ }
558
+
559
+ .workers-badge.multi {
560
+ color: var(--accent);
561
+ font-weight: 500;
562
+ }
327
563
  </style>