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.
- package/README.md +61 -3
- package/backend/app.js +5 -0
- package/backend/config/scripts/create-test.mjs +172 -0
- package/backend/config/scripts/generate-report.js +2 -1
- package/backend/middleware/jwtAuth.js +33 -0
- package/backend/middleware/requireAdmin.js +25 -0
- package/backend/package.json +2 -0
- package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
- package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
- package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
- package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
- package/backend/prisma/schema.prisma +118 -10
- package/backend/routes/auth.routes.js +96 -0
- package/backend/routes/settings.routes.js +44 -8
- package/backend/routes/test-cases.routes.js +80 -0
- package/backend/routes/test-runs.routes.js +122 -0
- package/backend/routes/test-suites.routes.js +92 -0
- package/backend/routes/users.routes.js +67 -0
- package/backend/scripts/create-test.js +7 -6
- package/backend/services/reportService.js +96 -4
- package/backend/services/settingsService.js +18 -2
- package/backend/services/testCaseService.js +139 -0
- package/backend/services/testRunService.js +203 -0
- package/backend/services/testSuiteService.js +191 -0
- package/backend/services/userService.js +114 -0
- package/backend/websockets/socketHandler.js +19 -6
- package/bin/plum.js +105 -9
- package/frontend/src/lib/api/auth.js +69 -0
- package/frontend/src/lib/api/repository.js +256 -0
- package/frontend/src/lib/api/users.js +52 -0
- package/frontend/src/lib/components/layout/Nav.svelte +116 -4
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
- package/frontend/src/lib/components/ui/Modal.svelte +8 -1
- package/frontend/src/lib/constants.js +2 -0
- package/frontend/src/lib/stores/auth.js +60 -0
- package/frontend/src/lib/stores/runner.js +9 -2
- package/frontend/src/routes/+layout.svelte +32 -4
- package/frontend/src/routes/+page.svelte +1 -1
- package/frontend/src/routes/login/+page.svelte +209 -0
- package/frontend/src/routes/settings/+page.svelte +586 -5
- package/frontend/src/routes/setup/+page.svelte +249 -0
- package/frontend/src/routes/test-repository/+page.svelte +1379 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
- 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: '
|
|
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
|
-
|
|
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={
|
|
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 (
|
|
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
|
-
<!--
|
|
316
|
-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 }}
|
|
@@ -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', {
|
|
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() {
|