plum-e2e 1.3.7 → 2.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 (62) hide show
  1. package/README.md +111 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/lib/runnerProcess.js +50 -4
  6. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  7. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  8. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  9. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  10. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  11. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  12. package/backend/middleware/jwtAuth.js +33 -0
  13. package/backend/middleware/requireAdmin.js +25 -0
  14. package/backend/package.json +2 -0
  15. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  16. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  17. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  18. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  19. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  20. package/backend/prisma/schema.prisma +123 -10
  21. package/backend/routes/auth.routes.js +96 -0
  22. package/backend/routes/node.routes.js +9 -0
  23. package/backend/routes/runners.routes.js +10 -0
  24. package/backend/routes/settings.routes.js +71 -8
  25. package/backend/routes/test-cases.routes.js +80 -0
  26. package/backend/routes/test-runs.routes.js +122 -0
  27. package/backend/routes/test-suites.routes.js +92 -0
  28. package/backend/routes/users.routes.js +67 -0
  29. package/backend/scripts/create-test.js +7 -6
  30. package/backend/scripts/manage-runners.mjs +49 -8
  31. package/backend/server.js +22 -1
  32. package/backend/services/cronService.js +91 -7
  33. package/backend/services/notificationService.js +163 -0
  34. package/backend/services/reportService.js +96 -4
  35. package/backend/services/settingsService.js +46 -2
  36. package/backend/services/testCaseService.js +139 -0
  37. package/backend/services/testRunService.js +203 -0
  38. package/backend/services/testSuiteService.js +191 -0
  39. package/backend/services/userService.js +114 -0
  40. package/backend/websockets/socketHandler.js +96 -7
  41. package/bin/plum.js +105 -9
  42. package/frontend/src/lib/api/auth.js +69 -0
  43. package/frontend/src/lib/api/repository.js +256 -0
  44. package/frontend/src/lib/api/schedules.js +5 -1
  45. package/frontend/src/lib/api/settings.js +15 -0
  46. package/frontend/src/lib/api/users.js +52 -0
  47. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  48. package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
  49. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  50. package/frontend/src/lib/constants.js +2 -0
  51. package/frontend/src/lib/stores/auth.js +60 -0
  52. package/frontend/src/lib/stores/runner.js +11 -2
  53. package/frontend/src/routes/+layout.svelte +32 -4
  54. package/frontend/src/routes/+page.svelte +1 -1
  55. package/frontend/src/routes/login/+page.svelte +209 -0
  56. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  57. package/frontend/src/routes/settings/+page.svelte +677 -6
  58. package/frontend/src/routes/setup/+page.svelte +249 -0
  59. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  60. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  61. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  62. package/package.json +1 -1
@@ -18,7 +18,6 @@
18
18
  <script>
19
19
  import { onMount, onDestroy } from 'svelte';
20
20
  import { fly, slide } from 'svelte/transition';
21
- import { goto } from '$app/navigation';
22
21
  import { io } from 'socket.io-client';
23
22
  import {
24
23
  socket,
@@ -29,17 +28,27 @@
29
28
  triggerRun,
30
29
  testsVersion,
31
30
  reportsVersion,
31
+ runsVersion,
32
32
  activeCronJobs
33
33
  } from '$lib/stores/runner';
34
34
  import { fetchLatestReportId, reportUrl } from '$lib/api/reports';
35
35
  import { fetchRunners } from '$lib/api/runners';
36
+ import { fetchRuns, fetchRun } from '$lib/api/repository';
37
+ import { fetchIntegrations } from '$lib/api/settings';
36
38
  import { API_BASE, BROWSERS } from '$lib/constants';
37
39
  import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
38
40
 
39
41
  let availableRunners = [];
42
+ let testRuns = [];
43
+ let selectedRun = null; // { id, title, tags: string[] | null }
44
+ let selectedRunLoading = false;
40
45
  let browserOpen = false;
41
46
  let runnersOpen = false;
47
+ let runPickOpen = false;
42
48
  let runAllModalOpen = false;
49
+ let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
50
+ let notifyDiscord = false;
51
+ let notifySlack = false;
43
52
 
44
53
  function clickOutside(node) {
45
54
  function handle(e) {
@@ -82,6 +91,10 @@
82
91
  })
83
92
  .catch(() => {});
84
93
 
94
+ fetchIntegrations()
95
+ .then((i) => (integrations = i))
96
+ .catch(() => {});
97
+
85
98
  _unsubConfig = runnerConfig.subscribe((v) => {
86
99
  try {
87
100
  localStorage.setItem('plum:runnerConfig', JSON.stringify(v));
@@ -105,7 +118,7 @@
105
118
  });
106
119
  });
107
120
 
108
- const s = io(API_BASE);
121
+ const s = io(API_BASE, { transports: ['websocket'] });
109
122
  _socket = s;
110
123
  socket.set(s);
111
124
 
@@ -189,6 +202,11 @@
189
202
  $: anyCronRunning = cronJobs.length > 0;
190
203
  $: anyRunning = state.running || anyCronRunning;
191
204
 
205
+ $: if ($runsVersion >= 0)
206
+ fetchRuns({ limit: 200 })
207
+ .then((r) => (testRuns = r.runs))
208
+ .catch(() => {});
209
+
192
210
  $: statusColor =
193
211
  state.status === 'pass'
194
212
  ? 'var(--pass)'
@@ -219,16 +237,42 @@
219
237
  ? (availableRunners.find((r) => r.id === cfg.selectedRunners[0])?.name ?? '1 node')
220
238
  : `${cfg.selectedRunners.length} nodes`;
221
239
 
240
+ async function selectRun(run) {
241
+ runPickOpen = false;
242
+ selectedRun = { id: run.id, title: run.title, tags: null };
243
+ selectedRunLoading = true;
244
+ try {
245
+ const full = await fetchRun(run.id);
246
+ const tags = full.entries
247
+ .filter((e) => e.case?.isAutomated)
248
+ .map((e) => `@${e.case.displayId}`);
249
+ selectedRun = { id: run.id, title: run.title, tags };
250
+ } catch {
251
+ selectedRun = null;
252
+ } finally {
253
+ selectedRunLoading = false;
254
+ }
255
+ }
256
+
257
+ function clearSelectedRun() {
258
+ selectedRun = null;
259
+ }
260
+
222
261
  function handleRunClick() {
223
- if ($runnerConfig.testID.trim() === '') {
262
+ const notify = { notifyDiscord, notifySlack };
263
+ if (selectedRun) {
264
+ if (selectedRunLoading || !selectedRun.tags) return;
265
+ if (selectedRun.tags.length === 0) return;
266
+ triggerRun(selectedRun.tags.join(' or '), selectedRun.id, notify);
267
+ } else if ($runnerConfig.testID.trim() === '') {
224
268
  runAllModalOpen = true;
225
269
  } else {
226
- triggerRun();
270
+ triggerRun(undefined, undefined, notify);
227
271
  }
228
272
  }
229
273
 
230
274
  function handleKeydown(e) {
231
- if (e.key === 'Enter' && !state.running) handleRunClick();
275
+ if (e.key === 'Enter' && !state.running && !selectedRun) handleRunClick();
232
276
  }
233
277
 
234
278
  function adjustWorkers(delta) {
@@ -259,7 +303,7 @@
259
303
  confirmLabel="Run all tests"
260
304
  on:confirm={() => {
261
305
  runAllModalOpen = false;
262
- triggerRun();
306
+ triggerRun(undefined, undefined, { notifyDiscord, notifySlack });
263
307
  }}
264
308
  >
265
309
  No tag or filter is set. This will run <strong>every test</strong> in the suite, which may take a while.
@@ -312,30 +356,115 @@
312
356
 
313
357
  <!-- Middle: tag filter + browser + workers + runner dropdowns -->
314
358
  <div class="bar-center">
315
- <!-- Tag input -->
316
- <div class="input-wrap">
317
- <svg
318
- class="input-icon"
319
- width="12"
320
- height="12"
321
- viewBox="0 0 24 24"
322
- fill="none"
323
- stroke="currentColor"
324
- stroke-width="2"
325
- stroke-linecap="round"
326
- stroke-linejoin="round"
327
- >
328
- <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
329
- </svg>
330
- <input
331
- type="text"
332
- class="tag-input"
333
- value={$runnerConfig.testID}
334
- placeholder="@tag or leave blank for all tests"
335
- on:input={(e) => runnerConfig.update((c) => ({ ...c, testID: e.currentTarget.value }))}
336
- on:keydown={handleKeydown}
337
- disabled={state.running}
338
- />
359
+ <!-- Test Run picker OR tag input -->
360
+ {#if selectedRun}
361
+ <div class="run-chip" class:loading={selectedRunLoading}>
362
+ <svg
363
+ width="9"
364
+ height="10"
365
+ viewBox="0 0 10 12"
366
+ fill="currentColor"
367
+ stroke="none"
368
+ style="opacity:0.6;flex-shrink:0"><polygon points="0,0 10,6 0,12" /></svg
369
+ >
370
+ <span class="run-chip-title">{selectedRun.title}</span>
371
+ {#if selectedRunLoading}
372
+ <span class="run-chip-spinner"></span>
373
+ {:else if selectedRun.tags !== null}
374
+ <span class="run-chip-count" class:none={selectedRun.tags.length === 0}>
375
+ {selectedRun.tags.length} automated
376
+ </span>
377
+ {/if}
378
+ <button class="run-chip-clear" on:click={clearSelectedRun} aria-label="Clear test run">
379
+ <svg width="9" height="9" viewBox="0 0 14 14" fill="none"
380
+ ><path
381
+ d="M1 1l12 12M13 1L1 13"
382
+ stroke="currentColor"
383
+ stroke-width="1.5"
384
+ stroke-linecap="round"
385
+ /></svg
386
+ >
387
+ </button>
388
+ </div>
389
+ {:else}
390
+ <div class="input-wrap">
391
+ <svg
392
+ class="input-icon"
393
+ width="12"
394
+ height="12"
395
+ viewBox="0 0 24 24"
396
+ fill="none"
397
+ stroke="currentColor"
398
+ stroke-width="2"
399
+ stroke-linecap="round"
400
+ stroke-linejoin="round"
401
+ >
402
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
403
+ </svg>
404
+ <input
405
+ type="text"
406
+ class="tag-input"
407
+ value={$runnerConfig.testID}
408
+ placeholder="@tag or leave blank for all tests"
409
+ on:input={(e) => runnerConfig.update((c) => ({ ...c, testID: e.currentTarget.value }))}
410
+ on:keydown={handleKeydown}
411
+ disabled={state.running}
412
+ />
413
+ </div>
414
+ {/if}
415
+
416
+ <!-- Test run dropdown -->
417
+ <div class="ctrl-group">
418
+ <span class="ctrl-label">Test Run</span>
419
+ <div class="dropdown-wrap" use:clickOutside on:clickoutside={() => (runPickOpen = false)}>
420
+ <button
421
+ class="dropdown-trigger"
422
+ class:open={runPickOpen}
423
+ class:has-remote={!!selectedRun}
424
+ on:click={() => {
425
+ if (!state.running) runPickOpen = !runPickOpen;
426
+ }}
427
+ disabled={state.running}
428
+ >
429
+ <span>{selectedRun ? selectedRun.title : 'None'}</span>
430
+ <svg
431
+ width="10"
432
+ height="10"
433
+ viewBox="0 0 24 24"
434
+ fill="none"
435
+ stroke="currentColor"
436
+ stroke-width="2.5"
437
+ stroke-linecap="round"
438
+ class="trigger-chevron"
439
+ class:open={runPickOpen}
440
+ >
441
+ <polyline points="6 9 12 15 18 9" />
442
+ </svg>
443
+ </button>
444
+ {#if runPickOpen}
445
+ <div class="dropdown-menu run-pick-menu" transition:fly={{ y: 6, duration: 130 }}>
446
+ {#if selectedRun}
447
+ <button class="dropdown-item" on:click={clearSelectedRun}>
448
+ <span style="color:var(--text-muted)">✕</span> Clear
449
+ </button>
450
+ <div class="dropdown-divider"></div>
451
+ {/if}
452
+ {#if testRuns.filter((r) => r.status !== 'complete').length === 0}
453
+ <div class="dropdown-empty">No active test runs</div>
454
+ {:else}
455
+ {#each testRuns.filter((r) => r.status !== 'complete') as run}
456
+ <button
457
+ class="dropdown-item"
458
+ class:active={selectedRun?.id === run.id}
459
+ on:click={() => selectRun(run)}
460
+ >
461
+ {run.title}
462
+ </button>
463
+ {/each}
464
+ {/if}
465
+ </div>
466
+ {/if}
467
+ </div>
339
468
  </div>
340
469
 
341
470
  <div class="ctrl-divider"></div>
@@ -466,6 +595,35 @@
466
595
  </div>
467
596
  {/if}
468
597
 
598
+ {#if integrations.discordWebhookUrl || integrations.slackWebhookUrl}
599
+ <div class="ctrl-divider"></div>
600
+ <div class="ctrl-group">
601
+ <span class="ctrl-label">Notify</span>
602
+ <div class="notify-toggles">
603
+ {#if integrations.discordWebhookUrl}
604
+ <button
605
+ type="button"
606
+ class="notify-btn"
607
+ class:active={notifyDiscord}
608
+ on:click={() => (notifyDiscord = !notifyDiscord)}
609
+ title={notifyDiscord ? 'Discord notification on' : 'Discord notification off'}
610
+ disabled={state.running}>Discord</button
611
+ >
612
+ {/if}
613
+ {#if integrations.slackWebhookUrl}
614
+ <button
615
+ type="button"
616
+ class="notify-btn"
617
+ class:active={notifySlack}
618
+ on:click={() => (notifySlack = !notifySlack)}
619
+ title={notifySlack ? 'Slack notification on' : 'Slack notification off'}
620
+ disabled={state.running}>Slack</button
621
+ >
622
+ {/if}
623
+ </div>
624
+ </div>
625
+ {/if}
626
+
469
627
  <div class="ctrl-divider"></div>
470
628
 
471
629
  <!-- Run button -->
@@ -473,7 +631,12 @@
473
631
  class="run-btn"
474
632
  class:is-running={state.running}
475
633
  on:click={handleRunClick}
476
- disabled={state.running}
634
+ disabled={state.running ||
635
+ selectedRunLoading ||
636
+ (selectedRun && selectedRun.tags?.length === 0)}
637
+ title={selectedRun && selectedRun.tags?.length === 0
638
+ ? 'No automated cases in this run'
639
+ : undefined}
477
640
  >
478
641
  {#if state.running}
479
642
  <span class="run-spinner"></span>
@@ -1029,6 +1192,44 @@
1029
1192
  }
1030
1193
  }
1031
1194
 
1195
+ /* Notification toggles */
1196
+ .notify-toggles {
1197
+ display: flex;
1198
+ gap: 0.25rem;
1199
+ }
1200
+
1201
+ .notify-btn {
1202
+ height: 26px;
1203
+ padding: 0 0.5rem;
1204
+ border-radius: var(--radius-sm);
1205
+ border: 1px solid var(--border);
1206
+ background: transparent;
1207
+ color: var(--text-muted);
1208
+ font-size: 0.75rem;
1209
+ font-family: inherit;
1210
+ cursor: pointer;
1211
+ transition:
1212
+ background var(--duration-fast),
1213
+ color var(--duration-fast),
1214
+ border-color var(--duration-fast);
1215
+ }
1216
+
1217
+ .notify-btn:hover:not(:disabled) {
1218
+ color: var(--text);
1219
+ border-color: var(--text-muted);
1220
+ }
1221
+
1222
+ .notify-btn.active {
1223
+ background: var(--accent);
1224
+ border-color: var(--accent);
1225
+ color: #fff;
1226
+ }
1227
+
1228
+ .notify-btn:disabled {
1229
+ opacity: 0.4;
1230
+ cursor: default;
1231
+ }
1232
+
1032
1233
  /* Expand button */
1033
1234
  .expand-btn {
1034
1235
  display: flex;
@@ -1187,4 +1388,93 @@
1187
1388
  .empty-icon {
1188
1389
  opacity: 0.4;
1189
1390
  }
1391
+
1392
+ /* ── Run chip (selected test run display) ── */
1393
+ .run-chip {
1394
+ display: inline-flex;
1395
+ align-items: center;
1396
+ gap: 0.4rem;
1397
+ height: 28px;
1398
+ padding: 0 0.5rem 0 0.625rem;
1399
+ background: var(--accent-soft);
1400
+ border: 1px solid var(--accent);
1401
+ border-radius: var(--radius-sm);
1402
+ color: var(--accent);
1403
+ font-size: 0.78rem;
1404
+ white-space: nowrap;
1405
+ max-width: 200px;
1406
+ }
1407
+
1408
+ .run-chip-title {
1409
+ overflow: hidden;
1410
+ text-overflow: ellipsis;
1411
+ white-space: nowrap;
1412
+ flex: 1;
1413
+ min-width: 0;
1414
+ }
1415
+
1416
+ .run-chip-count {
1417
+ font-size: 0.65rem;
1418
+ font-weight: 600;
1419
+ letter-spacing: 0.04em;
1420
+ opacity: 0.75;
1421
+ white-space: nowrap;
1422
+ flex-shrink: 0;
1423
+ }
1424
+
1425
+ .run-chip-count.none {
1426
+ color: var(--fail);
1427
+ opacity: 1;
1428
+ }
1429
+
1430
+ .run-chip-spinner {
1431
+ width: 9px;
1432
+ height: 9px;
1433
+ border: 1.5px solid color-mix(in srgb, var(--accent) 35%, transparent);
1434
+ border-top-color: var(--accent);
1435
+ border-radius: 50%;
1436
+ animation: spin 0.65s linear infinite;
1437
+ flex-shrink: 0;
1438
+ }
1439
+
1440
+ .run-chip-clear {
1441
+ display: flex;
1442
+ align-items: center;
1443
+ justify-content: center;
1444
+ width: 16px;
1445
+ height: 16px;
1446
+ border: none;
1447
+ background: transparent;
1448
+ color: var(--accent);
1449
+ cursor: pointer;
1450
+ border-radius: 2px;
1451
+ opacity: 0.7;
1452
+ padding: 0;
1453
+ flex-shrink: 0;
1454
+ transition: opacity var(--duration-fast);
1455
+ }
1456
+
1457
+ .run-chip-clear:hover {
1458
+ opacity: 1;
1459
+ }
1460
+
1461
+ .run-pick-menu {
1462
+ min-width: 180px;
1463
+ max-height: 240px;
1464
+ overflow-y: auto;
1465
+ }
1466
+
1467
+ .dropdown-divider {
1468
+ height: 1px;
1469
+ background: var(--border);
1470
+ margin: 0.25rem 0;
1471
+ }
1472
+
1473
+ .dropdown-empty {
1474
+ font-size: 0.8125rem;
1475
+ color: var(--text-muted);
1476
+ padding: 0.35rem 0.5rem;
1477
+ text-align: center;
1478
+ opacity: 0.7;
1479
+ }
1190
1480
  </style>
@@ -22,12 +22,18 @@
22
22
  export let open = false;
23
23
  export let title = '';
24
24
 
25
+ let mousedownOnBackdrop = false;
26
+
25
27
  function close() {
26
28
  open = false;
27
29
  }
28
30
 
31
+ function onBackdropMousedown(e) {
32
+ mousedownOnBackdrop = e.target === e.currentTarget;
33
+ }
34
+
29
35
  function onBackdrop(e) {
30
- if (e.target === e.currentTarget) close();
36
+ if (e.target === e.currentTarget && mousedownOnBackdrop) close();
31
37
  }
32
38
 
33
39
  function onKeydown(e) {
@@ -41,6 +47,7 @@
41
47
  <div
42
48
  class="backdrop"
43
49
  role="presentation"
50
+ on:mousedown={onBackdropMousedown}
44
51
  on:click={onBackdrop}
45
52
  on:keydown={(e) => e.key === 'Escape' && close()}
46
53
  transition:fade={{ duration: 200 }}
@@ -28,6 +28,8 @@ export const TRIGGER_TYPES = Object.freeze({
28
28
  });
29
29
 
30
30
  export const REPORTS_PER_PAGE = 15;
31
+ export const REPO_PAGE_SIZE = 20;
32
+ export const SUITE_CASES_PER_PAGE = 20;
31
33
 
32
34
  export const COPY_TIMEOUT_MS = 1400;
33
35
  export const TOAST_TIMEOUT_MS = 4000;
@@ -0,0 +1,60 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ import { writable } from 'svelte/store';
19
+
20
+ const TOKEN_KEY = 'plum:token';
21
+ const USER_KEY = 'plum:user';
22
+
23
+ function createAuthStore() {
24
+ let initialToken = null;
25
+ let initialUser = null;
26
+ try {
27
+ initialToken = typeof localStorage !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null;
28
+ const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(USER_KEY) : null;
29
+ initialUser = raw ? JSON.parse(raw) : null;
30
+ } catch {}
31
+
32
+ const { subscribe, set, update } = writable({ token: initialToken, user: initialUser });
33
+
34
+ return {
35
+ subscribe,
36
+ login(token, user) {
37
+ try {
38
+ localStorage.setItem(TOKEN_KEY, token);
39
+ localStorage.setItem(USER_KEY, JSON.stringify(user));
40
+ } catch {}
41
+ set({ token, user });
42
+ },
43
+ logout() {
44
+ try {
45
+ localStorage.removeItem(TOKEN_KEY);
46
+ localStorage.removeItem(USER_KEY);
47
+ } catch {}
48
+ set({ token: null, user: null });
49
+ },
50
+ getToken() {
51
+ try {
52
+ return localStorage.getItem(TOKEN_KEY);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ };
58
+ }
59
+
60
+ export const auth = createAuthStore();
@@ -43,11 +43,12 @@ export const builtInEnabled = writable(true);
43
43
 
44
44
  export const testsVersion = writable(0);
45
45
  export const reportsVersion = writable(0);
46
+ export const runsVersion = writable(0);
46
47
 
47
48
  // Map of taskName → true for every cron job currently executing
48
49
  export const activeCronJobs = writable({});
49
50
 
50
- export function triggerRun(id) {
51
+ export function triggerRun(id, testRunId, notify = {}) {
51
52
  const s = get(socket);
52
53
  if (!s) return;
53
54
 
@@ -66,7 +67,15 @@ export function triggerRun(id) {
66
67
  });
67
68
  panelExpanded.set(true);
68
69
 
69
- s.emit('run-test', { tag: runId, workers, browser, runners: selectedRunners });
70
+ s.emit('run-test', {
71
+ tag: runId,
72
+ workers,
73
+ browser,
74
+ runners: selectedRunners,
75
+ testRunId: testRunId ?? null,
76
+ notifyDiscord: notify.notifyDiscord ?? false,
77
+ notifySlack: notify.notifySlack ?? false
78
+ });
70
79
  }
71
80
 
72
81
  export function cancelRun() {
@@ -17,13 +17,41 @@
17
17
 
18
18
  <script>
19
19
  import '../app.css';
20
+ import { onMount } from 'svelte';
21
+ import { goto } from '$app/navigation';
22
+ import { page } from '$app/stores';
20
23
  import Nav from '$lib/components/layout/Nav.svelte';
21
24
  import PageShell from '$lib/components/layout/PageShell.svelte';
22
25
  import RunnerPanel from '$lib/components/layout/RunnerPanel.svelte';
26
+ import { auth } from '$lib/stores/auth';
27
+ import { checkNeedsSetup } from '$lib/api/auth';
28
+
29
+ const PUBLIC_ROUTES = ['/login', '/setup'];
30
+
31
+ let ready = true;
32
+
33
+ onMount(async () => {
34
+ const pathname = $page.url.pathname;
35
+ if (PUBLIC_ROUTES.includes(pathname)) return;
36
+
37
+ const token = $auth.token;
38
+ if (!token) {
39
+ try {
40
+ const needsSetup = await checkNeedsSetup();
41
+ goto(needsSetup ? '/setup' : '/login');
42
+ } catch {
43
+ goto('/login');
44
+ }
45
+ }
46
+ });
23
47
  </script>
24
48
 
25
- <Nav />
26
- <PageShell>
49
+ {#if $page.url.pathname === '/login' || $page.url.pathname === '/setup'}
27
50
  <slot />
28
- </PageShell>
29
- <RunnerPanel />
51
+ {:else}
52
+ <Nav />
53
+ <PageShell>
54
+ <slot />
55
+ </PageShell>
56
+ <RunnerPanel />
57
+ {/if}
@@ -106,7 +106,7 @@
106
106
  $: visibleTests = filtered.reduce((n, s) => n + s.tests.length, 0);
107
107
  </script>
108
108
 
109
- <svelte:head><title>Run Tests — Plum</title></svelte:head>
109
+ <svelte:head><title>Automated Tests — Plum</title></svelte:head>
110
110
 
111
111
  <div class="page-header">
112
112
  <div class="header-top">