plum-e2e 1.3.7 → 2.1.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 (45) hide show
  1. package/README.md +61 -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/middleware/jwtAuth.js +33 -0
  6. package/backend/middleware/requireAdmin.js +25 -0
  7. package/backend/package.json +2 -0
  8. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  9. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  10. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  11. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  12. package/backend/prisma/schema.prisma +118 -10
  13. package/backend/routes/auth.routes.js +96 -0
  14. package/backend/routes/settings.routes.js +44 -8
  15. package/backend/routes/test-cases.routes.js +80 -0
  16. package/backend/routes/test-runs.routes.js +122 -0
  17. package/backend/routes/test-suites.routes.js +92 -0
  18. package/backend/routes/users.routes.js +67 -0
  19. package/backend/scripts/create-test.js +7 -6
  20. package/backend/services/reportService.js +96 -4
  21. package/backend/services/settingsService.js +18 -2
  22. package/backend/services/testCaseService.js +139 -0
  23. package/backend/services/testRunService.js +203 -0
  24. package/backend/services/testSuiteService.js +191 -0
  25. package/backend/services/userService.js +114 -0
  26. package/backend/websockets/socketHandler.js +19 -6
  27. package/bin/plum.js +105 -9
  28. package/frontend/src/lib/api/auth.js +69 -0
  29. package/frontend/src/lib/api/repository.js +256 -0
  30. package/frontend/src/lib/api/users.js +52 -0
  31. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  32. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  33. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  34. package/frontend/src/lib/constants.js +2 -0
  35. package/frontend/src/lib/stores/auth.js +60 -0
  36. package/frontend/src/lib/stores/runner.js +9 -2
  37. package/frontend/src/routes/+layout.svelte +32 -4
  38. package/frontend/src/routes/+page.svelte +1 -1
  39. package/frontend/src/routes/login/+page.svelte +209 -0
  40. package/frontend/src/routes/settings/+page.svelte +586 -5
  41. package/frontend/src/routes/setup/+page.svelte +249 -0
  42. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  43. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  44. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  45. package/package.json +1 -1
@@ -0,0 +1,52 @@
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 { API_BASE } from '$lib/constants';
19
+ import { auth } from '$lib/stores/auth';
20
+
21
+ function authHeaders() {
22
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.getToken()}` };
23
+ }
24
+
25
+ export async function fetchUsers() {
26
+ const res = await fetch(`${API_BASE}/users`, { headers: authHeaders() });
27
+ if (!res.ok) throw new Error('Failed to fetch users');
28
+ const data = await res.json();
29
+ return data.users;
30
+ }
31
+
32
+ export async function createUser({ name, email, password, role = 'user' }) {
33
+ const res = await fetch(`${API_BASE}/users`, {
34
+ method: 'POST',
35
+ headers: authHeaders(),
36
+ body: JSON.stringify({ name, email, password, role })
37
+ });
38
+ const data = await res.json();
39
+ if (!res.ok) throw new Error(data.error ?? 'Failed to create user');
40
+ return data.user;
41
+ }
42
+
43
+ export async function deleteUser(id) {
44
+ const res = await fetch(`${API_BASE}/users/${id}`, {
45
+ method: 'DELETE',
46
+ headers: authHeaders()
47
+ });
48
+ if (!res.ok) {
49
+ const data = await res.json();
50
+ throw new Error(data.error ?? 'Failed to delete user');
51
+ }
52
+ }
@@ -18,13 +18,25 @@
18
18
  <script>
19
19
  import { page } from '$app/stores';
20
20
  import { slide } from 'svelte/transition';
21
+ import { onMount } from 'svelte';
22
+ import { auth } from '$lib/stores/auth';
23
+ import { goto } from '$app/navigation';
24
+ import { fetchProject } from '$lib/api/settings';
21
25
 
22
26
  let menuOpen = false;
27
+ let project = { name: '', logoUrl: '' };
28
+
29
+ onMount(async () => {
30
+ try {
31
+ project = await fetchProject();
32
+ } catch {}
33
+ });
23
34
 
24
35
  const links = [
25
- { href: '/', label: 'Run Tests' },
36
+ { href: '/', label: 'Automated Tests' },
26
37
  { href: '/reports', label: 'Reports' },
27
- { href: '/scheduled-tests', label: 'Scheduled' }
38
+ { href: '/scheduled-tests', label: 'Scheduled' },
39
+ { href: '/test-repository', label: 'Test Repository', sep: true }
28
40
  ];
29
41
 
30
42
  function closeMenu() {
@@ -40,13 +52,34 @@
40
52
 
41
53
  <div class="links">
42
54
  {#each links as link}
43
- <a href={link.href} class="link" class:active={$page.url.pathname === link.href}>
55
+ {#if link.sep}
56
+ <span class="nav-sep" aria-hidden="true"></span>
57
+ {/if}
58
+ <a
59
+ href={link.href}
60
+ class="link"
61
+ class:repo={link.sep}
62
+ class:active={link.href === '/'
63
+ ? $page.url.pathname === '/'
64
+ : $page.url.pathname.startsWith(link.href)}
65
+ >
44
66
  {link.label}
45
67
  </a>
46
68
  {/each}
47
69
  </div>
48
70
 
49
71
  <div class="actions">
72
+ {#if project.name}
73
+ <div class="project-card">
74
+ {#if project.logoUrl}
75
+ <img src={project.logoUrl} alt="" class="project-logo" />
76
+ {/if}
77
+ <span class="project-name">{project.name}</span>
78
+ </div>
79
+ {/if}
80
+ {#if $auth.user}
81
+ <span class="nav-user">{$auth.user.name}</span>
82
+ {/if}
50
83
  <a
51
84
  href="/settings"
52
85
  class="settings-btn"
@@ -87,10 +120,15 @@
87
120
  {#if menuOpen}
88
121
  <div class="mobile-menu" transition:slide={{ duration: 200 }}>
89
122
  {#each links as link}
123
+ {#if link.sep}
124
+ <hr class="mobile-sep" />
125
+ {/if}
90
126
  <a
91
127
  href={link.href}
92
128
  class="mobile-link"
93
- class:active={$page.url.pathname === link.href}
129
+ class:active={link.href === '/'
130
+ ? $page.url.pathname === '/'
131
+ : $page.url.pathname.startsWith(link.href)}
94
132
  on:click={closeMenu}
95
133
  >
96
134
  {link.label}
@@ -150,6 +188,36 @@
150
188
  color: var(--text);
151
189
  }
152
190
 
191
+ /* Project card */
192
+ .project-card {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 0.4rem;
196
+ padding: 0.25rem 0.6rem 0.25rem 0.5rem;
197
+ border: 1px solid var(--border);
198
+ border-radius: var(--radius-sm);
199
+ background: var(--bg-elevated);
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ .project-logo {
204
+ width: 16px;
205
+ height: 16px;
206
+ object-fit: contain;
207
+ border-radius: 2px;
208
+ flex-shrink: 0;
209
+ }
210
+
211
+ .project-name {
212
+ font-size: 0.78rem;
213
+ font-weight: 500;
214
+ color: var(--text);
215
+ max-width: 140px;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ }
220
+
153
221
  /* Desktop links */
154
222
  .links {
155
223
  display: flex;
@@ -179,6 +247,29 @@
179
247
  background: var(--accent-soft);
180
248
  }
181
249
 
250
+ .nav-sep {
251
+ display: block;
252
+ width: 1px;
253
+ height: 18px;
254
+ background: var(--border);
255
+ margin: 0 0.375rem;
256
+ flex-shrink: 0;
257
+ align-self: center;
258
+ }
259
+
260
+ .link.repo {
261
+ border: 1px solid var(--border);
262
+ padding: 0.3rem 0.75rem;
263
+ }
264
+
265
+ .link.repo:hover {
266
+ border-color: var(--text-muted);
267
+ }
268
+
269
+ .link.repo.active {
270
+ border-color: var(--accent);
271
+ }
272
+
182
273
  /* Actions */
183
274
  .actions {
184
275
  display: flex;
@@ -187,6 +278,21 @@
187
278
  margin-left: auto;
188
279
  }
189
280
 
281
+ .nav-user {
282
+ font-size: 0.8125rem;
283
+ color: var(--text-muted);
284
+ max-width: 120px;
285
+ overflow: hidden;
286
+ text-overflow: ellipsis;
287
+ white-space: nowrap;
288
+ }
289
+
290
+ @media (max-width: 640px) {
291
+ .nav-user {
292
+ display: none;
293
+ }
294
+ }
295
+
190
296
  /* Settings gear icon */
191
297
  .settings-btn {
192
298
  display: flex;
@@ -288,6 +394,12 @@
288
394
  background: var(--accent-soft);
289
395
  }
290
396
 
397
+ .mobile-sep {
398
+ border: none;
399
+ border-top: 1px solid var(--border);
400
+ margin: 0.375rem 0.75rem;
401
+ }
402
+
291
403
  @media (max-width: 640px) {
292
404
  .links {
293
405
  display: none;
@@ -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,16 +28,22 @@
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';
36
37
  import { API_BASE, BROWSERS } from '$lib/constants';
37
38
  import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
38
39
 
39
40
  let availableRunners = [];
41
+ let testRuns = [];
42
+ let selectedRun = null; // { id, title, tags: string[] | null }
43
+ let selectedRunLoading = false;
40
44
  let browserOpen = false;
41
45
  let runnersOpen = false;
46
+ let runPickOpen = false;
42
47
  let runAllModalOpen = false;
43
48
 
44
49
  function clickOutside(node) {
@@ -105,7 +110,7 @@
105
110
  });
106
111
  });
107
112
 
108
- const s = io(API_BASE);
113
+ const s = io(API_BASE, { transports: ['websocket'] });
109
114
  _socket = s;
110
115
  socket.set(s);
111
116
 
@@ -189,6 +194,11 @@
189
194
  $: anyCronRunning = cronJobs.length > 0;
190
195
  $: anyRunning = state.running || anyCronRunning;
191
196
 
197
+ $: if ($runsVersion >= 0)
198
+ fetchRuns({ limit: 200 })
199
+ .then((r) => (testRuns = r.runs))
200
+ .catch(() => {});
201
+
192
202
  $: statusColor =
193
203
  state.status === 'pass'
194
204
  ? 'var(--pass)'
@@ -219,8 +229,33 @@
219
229
  ? (availableRunners.find((r) => r.id === cfg.selectedRunners[0])?.name ?? '1 node')
220
230
  : `${cfg.selectedRunners.length} nodes`;
221
231
 
232
+ async function selectRun(run) {
233
+ runPickOpen = false;
234
+ selectedRun = { id: run.id, title: run.title, tags: null };
235
+ selectedRunLoading = true;
236
+ try {
237
+ const full = await fetchRun(run.id);
238
+ const tags = full.entries
239
+ .filter((e) => e.case?.isAutomated)
240
+ .map((e) => `@${e.case.displayId}`);
241
+ selectedRun = { id: run.id, title: run.title, tags };
242
+ } catch {
243
+ selectedRun = null;
244
+ } finally {
245
+ selectedRunLoading = false;
246
+ }
247
+ }
248
+
249
+ function clearSelectedRun() {
250
+ selectedRun = null;
251
+ }
252
+
222
253
  function handleRunClick() {
223
- if ($runnerConfig.testID.trim() === '') {
254
+ if (selectedRun) {
255
+ if (selectedRunLoading || !selectedRun.tags) return;
256
+ if (selectedRun.tags.length === 0) return;
257
+ triggerRun(selectedRun.tags.join(' or '), selectedRun.id);
258
+ } else if ($runnerConfig.testID.trim() === '') {
224
259
  runAllModalOpen = true;
225
260
  } else {
226
261
  triggerRun();
@@ -228,7 +263,7 @@
228
263
  }
229
264
 
230
265
  function handleKeydown(e) {
231
- if (e.key === 'Enter' && !state.running) handleRunClick();
266
+ if (e.key === 'Enter' && !state.running && !selectedRun) handleRunClick();
232
267
  }
233
268
 
234
269
  function adjustWorkers(delta) {
@@ -312,30 +347,115 @@
312
347
 
313
348
  <!-- Middle: tag filter + browser + workers + runner dropdowns -->
314
349
  <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
- />
350
+ <!-- Test Run picker OR tag input -->
351
+ {#if selectedRun}
352
+ <div class="run-chip" class:loading={selectedRunLoading}>
353
+ <svg
354
+ width="9"
355
+ height="10"
356
+ viewBox="0 0 10 12"
357
+ fill="currentColor"
358
+ stroke="none"
359
+ style="opacity:0.6;flex-shrink:0"><polygon points="0,0 10,6 0,12" /></svg
360
+ >
361
+ <span class="run-chip-title">{selectedRun.title}</span>
362
+ {#if selectedRunLoading}
363
+ <span class="run-chip-spinner"></span>
364
+ {:else if selectedRun.tags !== null}
365
+ <span class="run-chip-count" class:none={selectedRun.tags.length === 0}>
366
+ {selectedRun.tags.length} automated
367
+ </span>
368
+ {/if}
369
+ <button class="run-chip-clear" on:click={clearSelectedRun} aria-label="Clear test run">
370
+ <svg width="9" height="9" viewBox="0 0 14 14" fill="none"
371
+ ><path
372
+ d="M1 1l12 12M13 1L1 13"
373
+ stroke="currentColor"
374
+ stroke-width="1.5"
375
+ stroke-linecap="round"
376
+ /></svg
377
+ >
378
+ </button>
379
+ </div>
380
+ {:else}
381
+ <div class="input-wrap">
382
+ <svg
383
+ class="input-icon"
384
+ width="12"
385
+ height="12"
386
+ viewBox="0 0 24 24"
387
+ fill="none"
388
+ stroke="currentColor"
389
+ stroke-width="2"
390
+ stroke-linecap="round"
391
+ stroke-linejoin="round"
392
+ >
393
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
394
+ </svg>
395
+ <input
396
+ type="text"
397
+ class="tag-input"
398
+ value={$runnerConfig.testID}
399
+ placeholder="@tag or leave blank for all tests"
400
+ on:input={(e) => runnerConfig.update((c) => ({ ...c, testID: e.currentTarget.value }))}
401
+ on:keydown={handleKeydown}
402
+ disabled={state.running}
403
+ />
404
+ </div>
405
+ {/if}
406
+
407
+ <!-- Test run dropdown -->
408
+ <div class="ctrl-group">
409
+ <span class="ctrl-label">Test Run</span>
410
+ <div class="dropdown-wrap" use:clickOutside on:clickoutside={() => (runPickOpen = false)}>
411
+ <button
412
+ class="dropdown-trigger"
413
+ class:open={runPickOpen}
414
+ class:has-remote={!!selectedRun}
415
+ on:click={() => {
416
+ if (!state.running) runPickOpen = !runPickOpen;
417
+ }}
418
+ disabled={state.running}
419
+ >
420
+ <span>{selectedRun ? selectedRun.title : 'None'}</span>
421
+ <svg
422
+ width="10"
423
+ height="10"
424
+ viewBox="0 0 24 24"
425
+ fill="none"
426
+ stroke="currentColor"
427
+ stroke-width="2.5"
428
+ stroke-linecap="round"
429
+ class="trigger-chevron"
430
+ class:open={runPickOpen}
431
+ >
432
+ <polyline points="6 9 12 15 18 9" />
433
+ </svg>
434
+ </button>
435
+ {#if runPickOpen}
436
+ <div class="dropdown-menu run-pick-menu" transition:fly={{ y: 6, duration: 130 }}>
437
+ {#if selectedRun}
438
+ <button class="dropdown-item" on:click={clearSelectedRun}>
439
+ <span style="color:var(--text-muted)">✕</span> Clear
440
+ </button>
441
+ <div class="dropdown-divider"></div>
442
+ {/if}
443
+ {#if testRuns.filter((r) => r.status !== 'complete').length === 0}
444
+ <div class="dropdown-empty">No active test runs</div>
445
+ {:else}
446
+ {#each testRuns.filter((r) => r.status !== 'complete') as run}
447
+ <button
448
+ class="dropdown-item"
449
+ class:active={selectedRun?.id === run.id}
450
+ on:click={() => selectRun(run)}
451
+ >
452
+ {run.title}
453
+ </button>
454
+ {/each}
455
+ {/if}
456
+ </div>
457
+ {/if}
458
+ </div>
339
459
  </div>
340
460
 
341
461
  <div class="ctrl-divider"></div>
@@ -473,7 +593,12 @@
473
593
  class="run-btn"
474
594
  class:is-running={state.running}
475
595
  on:click={handleRunClick}
476
- disabled={state.running}
596
+ disabled={state.running ||
597
+ selectedRunLoading ||
598
+ (selectedRun && selectedRun.tags?.length === 0)}
599
+ title={selectedRun && selectedRun.tags?.length === 0
600
+ ? 'No automated cases in this run'
601
+ : undefined}
477
602
  >
478
603
  {#if state.running}
479
604
  <span class="run-spinner"></span>
@@ -1187,4 +1312,93 @@
1187
1312
  .empty-icon {
1188
1313
  opacity: 0.4;
1189
1314
  }
1315
+
1316
+ /* ── Run chip (selected test run display) ── */
1317
+ .run-chip {
1318
+ display: inline-flex;
1319
+ align-items: center;
1320
+ gap: 0.4rem;
1321
+ height: 28px;
1322
+ padding: 0 0.5rem 0 0.625rem;
1323
+ background: var(--accent-soft);
1324
+ border: 1px solid var(--accent);
1325
+ border-radius: var(--radius-sm);
1326
+ color: var(--accent);
1327
+ font-size: 0.78rem;
1328
+ white-space: nowrap;
1329
+ max-width: 200px;
1330
+ }
1331
+
1332
+ .run-chip-title {
1333
+ overflow: hidden;
1334
+ text-overflow: ellipsis;
1335
+ white-space: nowrap;
1336
+ flex: 1;
1337
+ min-width: 0;
1338
+ }
1339
+
1340
+ .run-chip-count {
1341
+ font-size: 0.65rem;
1342
+ font-weight: 600;
1343
+ letter-spacing: 0.04em;
1344
+ opacity: 0.75;
1345
+ white-space: nowrap;
1346
+ flex-shrink: 0;
1347
+ }
1348
+
1349
+ .run-chip-count.none {
1350
+ color: var(--fail);
1351
+ opacity: 1;
1352
+ }
1353
+
1354
+ .run-chip-spinner {
1355
+ width: 9px;
1356
+ height: 9px;
1357
+ border: 1.5px solid color-mix(in srgb, var(--accent) 35%, transparent);
1358
+ border-top-color: var(--accent);
1359
+ border-radius: 50%;
1360
+ animation: spin 0.65s linear infinite;
1361
+ flex-shrink: 0;
1362
+ }
1363
+
1364
+ .run-chip-clear {
1365
+ display: flex;
1366
+ align-items: center;
1367
+ justify-content: center;
1368
+ width: 16px;
1369
+ height: 16px;
1370
+ border: none;
1371
+ background: transparent;
1372
+ color: var(--accent);
1373
+ cursor: pointer;
1374
+ border-radius: 2px;
1375
+ opacity: 0.7;
1376
+ padding: 0;
1377
+ flex-shrink: 0;
1378
+ transition: opacity var(--duration-fast);
1379
+ }
1380
+
1381
+ .run-chip-clear:hover {
1382
+ opacity: 1;
1383
+ }
1384
+
1385
+ .run-pick-menu {
1386
+ min-width: 180px;
1387
+ max-height: 240px;
1388
+ overflow-y: auto;
1389
+ }
1390
+
1391
+ .dropdown-divider {
1392
+ height: 1px;
1393
+ background: var(--border);
1394
+ margin: 0.25rem 0;
1395
+ }
1396
+
1397
+ .dropdown-empty {
1398
+ font-size: 0.8125rem;
1399
+ color: var(--text-muted);
1400
+ padding: 0.35rem 0.5rem;
1401
+ text-align: center;
1402
+ opacity: 0.7;
1403
+ }
1190
1404
  </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) {
51
52
  const s = get(socket);
52
53
  if (!s) return;
53
54
 
@@ -66,7 +67,13 @@ 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
+ });
70
77
  }
71
78
 
72
79
  export function cancelRun() {