plum-e2e 1.3.6 → 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/runnerService.js +16 -1
- 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/scheduled-tests/+page.svelte +3 -1
- 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,1379 @@
|
|
|
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
|
+
<script>
|
|
19
|
+
import { onMount } from 'svelte';
|
|
20
|
+
import { fly } from 'svelte/transition';
|
|
21
|
+
import { fetchSuites, createSuite, deleteSuite, searchRepository } from '$lib/api/repository';
|
|
22
|
+
import { fetchRuns, createRun, duplicateRun, deleteRun } from '$lib/api/repository';
|
|
23
|
+
import { runsVersion } from '$lib/stores/runner';
|
|
24
|
+
import Pagination from '$lib/components/ui/Pagination.svelte';
|
|
25
|
+
import { REPO_PAGE_SIZE } from '$lib/constants';
|
|
26
|
+
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
27
|
+
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
28
|
+
import Modal from '$lib/components/ui/Modal.svelte';
|
|
29
|
+
import Toast from '$lib/components/ui/Toast.svelte';
|
|
30
|
+
import Button from '$lib/components/ui/Button.svelte';
|
|
31
|
+
import { TOAST_TIMEOUT_MS } from '$lib/constants';
|
|
32
|
+
|
|
33
|
+
/** @type {'suites' | 'runs'} */
|
|
34
|
+
let tab =
|
|
35
|
+
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:repo:tab')) || 'suites';
|
|
36
|
+
|
|
37
|
+
function setTab(t) {
|
|
38
|
+
tab = t;
|
|
39
|
+
try {
|
|
40
|
+
sessionStorage.setItem('plum:repo:tab', t);
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readSort(key, fallback) {
|
|
45
|
+
try {
|
|
46
|
+
const v = sessionStorage.getItem(key);
|
|
47
|
+
if (v) return JSON.parse(v);
|
|
48
|
+
} catch {}
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeSort(key, sort) {
|
|
53
|
+
try {
|
|
54
|
+
sessionStorage.setItem(key, JSON.stringify(sort));
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let suites = [];
|
|
59
|
+
let suitesTotal = 0;
|
|
60
|
+
let suitesPage = 1;
|
|
61
|
+
let suitesSort = readSort('plum:repo:suites:sort', { by: 'createdAt', order: 'desc' });
|
|
62
|
+
|
|
63
|
+
let runs = [];
|
|
64
|
+
let runsTotal = 0;
|
|
65
|
+
let runsPage = 1;
|
|
66
|
+
let runsSort = readSort('plum:repo:runs:sort', { by: 'createdAt', order: 'desc' });
|
|
67
|
+
|
|
68
|
+
const PRIORITY_ORDER = { Critical: 0, High: 1, Medium: 2, Low: 3 };
|
|
69
|
+
|
|
70
|
+
function sortByPriority(items, order) {
|
|
71
|
+
return [...items].sort((a, b) => {
|
|
72
|
+
const diff = (PRIORITY_ORDER[a.priority] ?? 4) - (PRIORITY_ORDER[b.priority] ?? 4);
|
|
73
|
+
return order === 'asc' ? diff : -diff;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let loading = true;
|
|
78
|
+
let toast = null;
|
|
79
|
+
|
|
80
|
+
// Search results (server-side when query is active)
|
|
81
|
+
let searchResults = null;
|
|
82
|
+
let searchLoading = false;
|
|
83
|
+
let searchTimer = null;
|
|
84
|
+
|
|
85
|
+
let suiteModalOpen = false;
|
|
86
|
+
let suiteForm = { name: '', description: '', priority: 'Medium' };
|
|
87
|
+
let suiteFormSaving = false;
|
|
88
|
+
let suiteFormError = '';
|
|
89
|
+
|
|
90
|
+
let runModalOpen = false;
|
|
91
|
+
let runForm = { title: '' };
|
|
92
|
+
let runFormSaving = false;
|
|
93
|
+
let runFormError = '';
|
|
94
|
+
|
|
95
|
+
let confirmDelete = null;
|
|
96
|
+
let confirmDeleteOpen = false;
|
|
97
|
+
|
|
98
|
+
let confirmDuplicate = null;
|
|
99
|
+
let confirmDuplicateOpen = false;
|
|
100
|
+
|
|
101
|
+
let search = '';
|
|
102
|
+
|
|
103
|
+
$: q = search.trim();
|
|
104
|
+
|
|
105
|
+
$: {
|
|
106
|
+
clearTimeout(searchTimer);
|
|
107
|
+
if (q) {
|
|
108
|
+
searchLoading = true;
|
|
109
|
+
searchResults = null;
|
|
110
|
+
searchTimer = setTimeout(async () => {
|
|
111
|
+
try {
|
|
112
|
+
searchResults = await searchRepository(q);
|
|
113
|
+
} catch {
|
|
114
|
+
searchResults = { suites: [], cases: [], runs: [] };
|
|
115
|
+
} finally {
|
|
116
|
+
searchLoading = false;
|
|
117
|
+
}
|
|
118
|
+
}, 300);
|
|
119
|
+
} else {
|
|
120
|
+
searchResults = null;
|
|
121
|
+
searchLoading = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
$: filteredCases = searchResults?.cases ?? [];
|
|
126
|
+
$: filteredSuites = searchResults?.suites ?? [];
|
|
127
|
+
$: filteredRuns = searchResults?.runs ?? [];
|
|
128
|
+
|
|
129
|
+
const PRIORITIES = ['Critical', 'High', 'Medium', 'Low'];
|
|
130
|
+
|
|
131
|
+
function showToast(type, message) {
|
|
132
|
+
toast = { type, message };
|
|
133
|
+
setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadSuites(page = 1) {
|
|
137
|
+
const isPrioritySort = suitesSort.by === 'priority';
|
|
138
|
+
const result = await fetchSuites({
|
|
139
|
+
page,
|
|
140
|
+
sortBy: isPrioritySort ? 'createdAt' : suitesSort.by,
|
|
141
|
+
sortOrder: suitesSort.order
|
|
142
|
+
});
|
|
143
|
+
suites = isPrioritySort ? sortByPriority(result.suites, suitesSort.order) : result.suites;
|
|
144
|
+
suitesTotal = result.total;
|
|
145
|
+
suitesPage = page;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadRuns(page = 1) {
|
|
149
|
+
const result = await fetchRuns({ page, sortBy: runsSort.by, sortOrder: runsSort.order });
|
|
150
|
+
runs = result.runs;
|
|
151
|
+
runsTotal = result.total;
|
|
152
|
+
runsPage = page;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function applySuitesSort(by) {
|
|
156
|
+
if (suitesSort.by === by) {
|
|
157
|
+
suitesSort.order = suitesSort.order === 'asc' ? 'desc' : 'asc';
|
|
158
|
+
} else {
|
|
159
|
+
suitesSort = { by, order: by === 'createdAt' ? 'desc' : 'asc' };
|
|
160
|
+
}
|
|
161
|
+
writeSort('plum:repo:suites:sort', suitesSort);
|
|
162
|
+
await loadSuites(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function applyRunsSort(by) {
|
|
166
|
+
if (runsSort.by === by) {
|
|
167
|
+
runsSort.order = runsSort.order === 'asc' ? 'desc' : 'asc';
|
|
168
|
+
} else {
|
|
169
|
+
runsSort = { by, order: by === 'createdAt' || by === 'updatedAt' ? 'desc' : 'asc' };
|
|
170
|
+
}
|
|
171
|
+
writeSort('plum:repo:runs:sort', runsSort);
|
|
172
|
+
await loadRuns(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
onMount(async () => {
|
|
176
|
+
try {
|
|
177
|
+
await Promise.all([loadSuites(1), loadRuns(1)]);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
showToast('error', 'Failed to load data');
|
|
180
|
+
} finally {
|
|
181
|
+
loading = false;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
async function handleCreateSuite() {
|
|
186
|
+
if (!suiteForm.name.trim()) {
|
|
187
|
+
suiteFormError = 'Name is required.';
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
suiteFormError = '';
|
|
191
|
+
suiteFormSaving = true;
|
|
192
|
+
try {
|
|
193
|
+
const suite = await createSuite(suiteForm);
|
|
194
|
+
suites = [...suites, suite];
|
|
195
|
+
suitesTotal += 1;
|
|
196
|
+
suiteModalOpen = false;
|
|
197
|
+
suiteForm = { name: '', description: '', priority: 'Medium' };
|
|
198
|
+
showToast('success', `Suite "${suite.name}" created.`);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
suiteFormError = e.message;
|
|
201
|
+
} finally {
|
|
202
|
+
suiteFormSaving = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function handleDeleteSuite(id, name) {
|
|
207
|
+
try {
|
|
208
|
+
await deleteSuite(id);
|
|
209
|
+
suites = suites.filter((s) => s.id !== id);
|
|
210
|
+
suitesTotal -= 1;
|
|
211
|
+
showToast('success', `Suite "${name}" deleted.`);
|
|
212
|
+
} catch {
|
|
213
|
+
showToast('error', 'Failed to delete suite.');
|
|
214
|
+
}
|
|
215
|
+
confirmDelete = null;
|
|
216
|
+
confirmDeleteOpen = false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleCreateRun() {
|
|
220
|
+
if (!runForm.title.trim()) {
|
|
221
|
+
runFormError = 'Title is required.';
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
runFormError = '';
|
|
225
|
+
runFormSaving = true;
|
|
226
|
+
try {
|
|
227
|
+
const run = await createRun({ title: runForm.title, caseIds: [] });
|
|
228
|
+
runs = [run, ...runs];
|
|
229
|
+
runModalOpen = false;
|
|
230
|
+
runForm = { title: '' };
|
|
231
|
+
window.location.href = `/test-repository/runs/${run.id}`;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
runFormError = e.message;
|
|
234
|
+
} finally {
|
|
235
|
+
runFormSaving = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function handleDuplicateRun(run) {
|
|
240
|
+
confirmDuplicate = run;
|
|
241
|
+
confirmDuplicateOpen = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function executeDuplicate() {
|
|
245
|
+
if (!confirmDuplicate) return;
|
|
246
|
+
const run = confirmDuplicate;
|
|
247
|
+
confirmDuplicate = null;
|
|
248
|
+
confirmDuplicateOpen = false;
|
|
249
|
+
try {
|
|
250
|
+
const copy = await duplicateRun(run.id);
|
|
251
|
+
runs = [copy, ...runs];
|
|
252
|
+
runsTotal += 1;
|
|
253
|
+
runsVersion.update((v) => v + 1);
|
|
254
|
+
showToast('success', `Duplicated as "${copy.title}".`);
|
|
255
|
+
} catch {
|
|
256
|
+
showToast('error', 'Failed to duplicate run.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleDeleteRun(id, title) {
|
|
261
|
+
try {
|
|
262
|
+
await deleteRun(id);
|
|
263
|
+
runs = runs.filter((r) => r.id !== id);
|
|
264
|
+
runsTotal -= 1;
|
|
265
|
+
runsVersion.update((v) => v + 1);
|
|
266
|
+
showToast('success', `Run "${title}" deleted.`);
|
|
267
|
+
} catch {
|
|
268
|
+
showToast('error', 'Failed to delete run.');
|
|
269
|
+
}
|
|
270
|
+
confirmDelete = null;
|
|
271
|
+
confirmDeleteOpen = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function priorityClass(p) {
|
|
275
|
+
return p?.toLowerCase() ?? 'medium';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function runStatusClass(s) {
|
|
279
|
+
return s === 'complete' ? 'pass' : s === 'in-progress' ? 'warn' : 'muted';
|
|
280
|
+
}
|
|
281
|
+
</script>
|
|
282
|
+
|
|
283
|
+
<svelte:head><title>Test Repository — Plum</title></svelte:head>
|
|
284
|
+
|
|
285
|
+
<Toast {toast} />
|
|
286
|
+
|
|
287
|
+
<ConfirmModal
|
|
288
|
+
bind:open={confirmDeleteOpen}
|
|
289
|
+
title="Delete {confirmDelete?.type === 'suite' ? 'Suite' : 'Run'}"
|
|
290
|
+
confirmLabel="Delete"
|
|
291
|
+
on:confirm={() =>
|
|
292
|
+
confirmDelete?.type === 'suite'
|
|
293
|
+
? handleDeleteSuite(confirmDelete.id, confirmDelete.name)
|
|
294
|
+
: handleDeleteRun(confirmDelete.id, confirmDelete.name)}
|
|
295
|
+
>
|
|
296
|
+
{#if confirmDelete}
|
|
297
|
+
Delete <strong>"{confirmDelete.name}"</strong>? This cannot be undone.
|
|
298
|
+
{/if}
|
|
299
|
+
</ConfirmModal>
|
|
300
|
+
|
|
301
|
+
<ConfirmModal
|
|
302
|
+
bind:open={confirmDuplicateOpen}
|
|
303
|
+
title="Duplicate Run"
|
|
304
|
+
confirmLabel="Duplicate"
|
|
305
|
+
on:confirm={executeDuplicate}
|
|
306
|
+
>
|
|
307
|
+
{#if confirmDuplicate}
|
|
308
|
+
Duplicate <strong>"{confirmDuplicate.title}"</strong>? A copy will be added at the top of the
|
|
309
|
+
list.
|
|
310
|
+
{/if}
|
|
311
|
+
</ConfirmModal>
|
|
312
|
+
|
|
313
|
+
<Modal bind:open={suiteModalOpen} title="New Test Suite">
|
|
314
|
+
<div class="form-fields">
|
|
315
|
+
<div class="field">
|
|
316
|
+
<label class="field-label" for="suite-name">Name</label>
|
|
317
|
+
<input
|
|
318
|
+
id="suite-name"
|
|
319
|
+
type="text"
|
|
320
|
+
class="field-input"
|
|
321
|
+
bind:value={suiteForm.name}
|
|
322
|
+
placeholder="Login flows"
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="field">
|
|
326
|
+
<label class="field-label" for="suite-desc">Description</label>
|
|
327
|
+
<textarea
|
|
328
|
+
id="suite-desc"
|
|
329
|
+
class="field-input field-textarea"
|
|
330
|
+
bind:value={suiteForm.description}
|
|
331
|
+
placeholder="What this suite covers…"
|
|
332
|
+
rows="3"
|
|
333
|
+
></textarea>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="field">
|
|
336
|
+
<label class="field-label" for="suite-prio">Priority</label>
|
|
337
|
+
<select id="suite-prio" class="field-input" bind:value={suiteForm.priority}>
|
|
338
|
+
{#each PRIORITIES as p}<option value={p}>{p}</option>{/each}
|
|
339
|
+
</select>
|
|
340
|
+
</div>
|
|
341
|
+
{#if suiteFormError}<p class="form-error">{suiteFormError}</p>{/if}
|
|
342
|
+
<div class="modal-actions">
|
|
343
|
+
<Button on:click={handleCreateSuite} disabled={suiteFormSaving}>
|
|
344
|
+
{suiteFormSaving ? 'Creating…' : 'Create Suite'}
|
|
345
|
+
</Button>
|
|
346
|
+
<Button
|
|
347
|
+
variant="ghost"
|
|
348
|
+
on:click={() => {
|
|
349
|
+
suiteModalOpen = false;
|
|
350
|
+
suiteFormError = '';
|
|
351
|
+
}}>Cancel</Button
|
|
352
|
+
>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</Modal>
|
|
356
|
+
|
|
357
|
+
<Modal bind:open={runModalOpen} title="New Test Run">
|
|
358
|
+
<div class="form-fields">
|
|
359
|
+
<div class="field">
|
|
360
|
+
<label class="field-label" for="run-title">Title</label>
|
|
361
|
+
<input
|
|
362
|
+
id="run-title"
|
|
363
|
+
type="text"
|
|
364
|
+
class="field-input"
|
|
365
|
+
bind:value={runForm.title}
|
|
366
|
+
placeholder="Sprint 12 regression"
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
{#if runFormError}<p class="form-error">{runFormError}</p>{/if}
|
|
370
|
+
<div class="modal-actions">
|
|
371
|
+
<Button on:click={handleCreateRun} disabled={runFormSaving}>
|
|
372
|
+
{runFormSaving ? 'Creating…' : 'Create Run'}
|
|
373
|
+
</Button>
|
|
374
|
+
<Button
|
|
375
|
+
variant="ghost"
|
|
376
|
+
on:click={() => {
|
|
377
|
+
runModalOpen = false;
|
|
378
|
+
runFormError = '';
|
|
379
|
+
}}>Cancel</Button
|
|
380
|
+
>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</Modal>
|
|
384
|
+
|
|
385
|
+
<div class="page-header">
|
|
386
|
+
<div class="header-text">
|
|
387
|
+
<h1>Test Repository</h1>
|
|
388
|
+
<p class="header-desc">Manage test suites, cases, and track manual test runs.</p>
|
|
389
|
+
</div>
|
|
390
|
+
<div class="header-actions">
|
|
391
|
+
{#if tab === 'suites'}
|
|
392
|
+
<Button on:click={() => (suiteModalOpen = true)}>+ New Suite</Button>
|
|
393
|
+
{:else}
|
|
394
|
+
<Button on:click={() => (runModalOpen = true)}>+ New Run</Button>
|
|
395
|
+
{/if}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<div class="tabs">
|
|
400
|
+
<button class="tab" class:active={tab === 'suites'} on:click={() => setTab('suites')}>
|
|
401
|
+
Suites
|
|
402
|
+
<span class="tab-count">{q ? filteredSuites.length + '/' : ''}{suitesTotal}</span>
|
|
403
|
+
</button>
|
|
404
|
+
<button class="tab" class:active={tab === 'runs'} on:click={() => setTab('runs')}>
|
|
405
|
+
Test Runs
|
|
406
|
+
<span class="tab-count">{q ? filteredRuns.length + '/' : ''}{runsTotal}</span>
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<div class="search-bar">
|
|
411
|
+
<svg
|
|
412
|
+
class="search-icon"
|
|
413
|
+
width="14"
|
|
414
|
+
height="14"
|
|
415
|
+
viewBox="0 0 24 24"
|
|
416
|
+
fill="none"
|
|
417
|
+
stroke="currentColor"
|
|
418
|
+
stroke-width="2"
|
|
419
|
+
stroke-linecap="round"
|
|
420
|
+
stroke-linejoin="round"
|
|
421
|
+
>
|
|
422
|
+
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
423
|
+
</svg>
|
|
424
|
+
<input
|
|
425
|
+
type="search"
|
|
426
|
+
class="search-input"
|
|
427
|
+
placeholder="Search by ID or name…"
|
|
428
|
+
bind:value={search}
|
|
429
|
+
/>
|
|
430
|
+
{#if search}
|
|
431
|
+
<button class="search-clear" on:click={() => (search = '')} aria-label="Clear search">
|
|
432
|
+
<svg
|
|
433
|
+
width="12"
|
|
434
|
+
height="12"
|
|
435
|
+
viewBox="0 0 14 14"
|
|
436
|
+
fill="none"
|
|
437
|
+
stroke="currentColor"
|
|
438
|
+
stroke-width="1.5"
|
|
439
|
+
stroke-linecap="round"><path d="M1 1l12 12M13 1L1 13" /></svg
|
|
440
|
+
>
|
|
441
|
+
</button>
|
|
442
|
+
{/if}
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{#if !q}
|
|
446
|
+
<div class="sort-bar">
|
|
447
|
+
<span class="sort-label">Sort by</span>
|
|
448
|
+
{#if tab === 'suites'}
|
|
449
|
+
{#each [['createdAt', 'Date Created'], ['displayId', 'ID'], ['name', 'Name'], ['priority', 'Priority']] as [val, label]}
|
|
450
|
+
<button
|
|
451
|
+
class="sort-chip"
|
|
452
|
+
class:active={suitesSort.by === val}
|
|
453
|
+
on:click={() => applySuitesSort(val)}
|
|
454
|
+
>
|
|
455
|
+
{label}
|
|
456
|
+
{#if suitesSort.by === val}
|
|
457
|
+
<span class="sort-dir">{suitesSort.order === 'asc' ? '↑' : '↓'}</span>
|
|
458
|
+
{/if}
|
|
459
|
+
</button>
|
|
460
|
+
{/each}
|
|
461
|
+
{:else}
|
|
462
|
+
{#each [['createdAt', 'Date Created'], ['updatedAt', 'Last Updated'], ['title', 'Name']] as [val, label]}
|
|
463
|
+
<button
|
|
464
|
+
class="sort-chip"
|
|
465
|
+
class:active={runsSort.by === val}
|
|
466
|
+
on:click={() => applyRunsSort(val)}
|
|
467
|
+
>
|
|
468
|
+
{label}
|
|
469
|
+
{#if runsSort.by === val}
|
|
470
|
+
<span class="sort-dir">{runsSort.order === 'asc' ? '↑' : '↓'}</span>
|
|
471
|
+
{/if}
|
|
472
|
+
</button>
|
|
473
|
+
{/each}
|
|
474
|
+
{/if}
|
|
475
|
+
</div>
|
|
476
|
+
{/if}
|
|
477
|
+
|
|
478
|
+
{#if q}
|
|
479
|
+
<!-- ── Global search results ── -->
|
|
480
|
+
<div transition:fly={{ y: 4, duration: 160 }}>
|
|
481
|
+
{#if searchLoading}
|
|
482
|
+
<div class="loading-row">Searching…</div>
|
|
483
|
+
{:else if !searchResults}
|
|
484
|
+
<div class="loading-row">Searching…</div>
|
|
485
|
+
{:else if filteredCases.length === 0 && filteredSuites.length === 0 && filteredRuns.length === 0}
|
|
486
|
+
<EmptyState title="No results" description="Nothing matches “{search}”." />
|
|
487
|
+
{:else}
|
|
488
|
+
{#if filteredCases.length > 0}
|
|
489
|
+
<div class="search-section">
|
|
490
|
+
<h2 class="search-section-title">
|
|
491
|
+
Cases <span class="tab-count">{filteredCases.length}</span>
|
|
492
|
+
</h2>
|
|
493
|
+
<div class="case-results">
|
|
494
|
+
{#each filteredCases as tc (tc.id)}
|
|
495
|
+
<a href="/test-repository/suites/{tc.suite.id}" class="case-result-row">
|
|
496
|
+
<span class="id-chip small">{tc.displayId}</span>
|
|
497
|
+
<span class="case-result-title">{tc.title}</span>
|
|
498
|
+
{#if tc.isAutomated}<span class="auto-badge">automated</span>{/if}
|
|
499
|
+
<span class="priority-badge small {priorityClass(tc.priority)}">{tc.priority}</span>
|
|
500
|
+
<span class="case-result-suite">{tc.suite.displayId} · {tc.suite.name}</span>
|
|
501
|
+
</a>
|
|
502
|
+
{/each}
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
{/if}
|
|
506
|
+
|
|
507
|
+
{#if filteredSuites.length > 0}
|
|
508
|
+
<div class="search-section">
|
|
509
|
+
<h2 class="search-section-title">
|
|
510
|
+
Suites <span class="tab-count">{filteredSuites.length}</span>
|
|
511
|
+
</h2>
|
|
512
|
+
<div class="suite-grid">
|
|
513
|
+
{#each filteredSuites as suite (suite.id)}
|
|
514
|
+
<div class="suite-card">
|
|
515
|
+
<a
|
|
516
|
+
href="/test-repository/suites/{suite.id}"
|
|
517
|
+
class="card-link"
|
|
518
|
+
aria-label={suite.name}
|
|
519
|
+
></a>
|
|
520
|
+
<div class="suite-card-header">
|
|
521
|
+
<span class="id-chip">{suite.displayId}</span>
|
|
522
|
+
<span class="priority-badge {priorityClass(suite.priority)}"
|
|
523
|
+
>{suite.priority}</span
|
|
524
|
+
>
|
|
525
|
+
<div class="suite-card-actions">
|
|
526
|
+
<button
|
|
527
|
+
class="icon-btn danger"
|
|
528
|
+
title="Delete suite"
|
|
529
|
+
on:click={() => {
|
|
530
|
+
confirmDelete = {
|
|
531
|
+
type: 'suite',
|
|
532
|
+
id: suite.id,
|
|
533
|
+
name: suite.name
|
|
534
|
+
};
|
|
535
|
+
confirmDeleteOpen = true;
|
|
536
|
+
}}
|
|
537
|
+
>
|
|
538
|
+
<svg
|
|
539
|
+
width="13"
|
|
540
|
+
height="13"
|
|
541
|
+
viewBox="0 0 24 24"
|
|
542
|
+
fill="none"
|
|
543
|
+
stroke="currentColor"
|
|
544
|
+
stroke-width="2"
|
|
545
|
+
stroke-linecap="round"
|
|
546
|
+
stroke-linejoin="round"
|
|
547
|
+
>
|
|
548
|
+
<polyline points="3 6 5 6 21 6" /><path
|
|
549
|
+
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
|
|
550
|
+
/><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
|
551
|
+
</svg>
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
<h3 class="suite-name">{suite.name}</h3>
|
|
556
|
+
{#if suite.description}
|
|
557
|
+
<p class="suite-desc">{suite.description}</p>
|
|
558
|
+
{/if}
|
|
559
|
+
<div class="suite-meta">
|
|
560
|
+
<span class="meta-item">
|
|
561
|
+
<svg
|
|
562
|
+
width="12"
|
|
563
|
+
height="12"
|
|
564
|
+
viewBox="0 0 24 24"
|
|
565
|
+
fill="none"
|
|
566
|
+
stroke="currentColor"
|
|
567
|
+
stroke-width="2"
|
|
568
|
+
><path
|
|
569
|
+
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
|
|
570
|
+
/></svg
|
|
571
|
+
>
|
|
572
|
+
{suite._count.cases} case{suite._count.cases !== 1 ? 's' : ''}
|
|
573
|
+
</span>
|
|
574
|
+
<span class="meta-item">by {suite.createdBy.name}</span>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
{/each}
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
{/if}
|
|
581
|
+
|
|
582
|
+
{#if filteredRuns.length > 0}
|
|
583
|
+
<div class="search-section">
|
|
584
|
+
<h2 class="search-section-title">
|
|
585
|
+
Runs <span class="tab-count">{filteredRuns.length}</span>
|
|
586
|
+
</h2>
|
|
587
|
+
<div class="runs-list">
|
|
588
|
+
{#each filteredRuns as run (run.id)}
|
|
589
|
+
<div class="run-row">
|
|
590
|
+
<a href="/test-repository/runs/{run.id}" class="card-link" aria-label={run.title}
|
|
591
|
+
></a>
|
|
592
|
+
<div class="run-row-main">
|
|
593
|
+
<span class="run-status-dot {runStatusClass(run.status)}"></span>
|
|
594
|
+
<span class="run-title">{run.title}</span>
|
|
595
|
+
<span class="run-count"
|
|
596
|
+
>{run._count.entries} case{run._count.entries !== 1 ? 's' : ''}</span
|
|
597
|
+
>
|
|
598
|
+
</div>
|
|
599
|
+
<div class="run-meta">
|
|
600
|
+
<span class="run-status-label">{run.status}</span>
|
|
601
|
+
<span class="meta-sep">·</span>
|
|
602
|
+
<span>by {run.createdBy.name}</span>
|
|
603
|
+
<span class="meta-sep">·</span>
|
|
604
|
+
<span>{new Date(run.createdAt).toLocaleDateString()}</span>
|
|
605
|
+
</div>
|
|
606
|
+
<div class="run-row-actions">
|
|
607
|
+
<button
|
|
608
|
+
class="icon-btn"
|
|
609
|
+
title="Duplicate run"
|
|
610
|
+
on:click={() => handleDuplicateRun(run)}
|
|
611
|
+
>
|
|
612
|
+
<svg
|
|
613
|
+
width="13"
|
|
614
|
+
height="13"
|
|
615
|
+
viewBox="0 0 24 24"
|
|
616
|
+
fill="none"
|
|
617
|
+
stroke="currentColor"
|
|
618
|
+
stroke-width="2"
|
|
619
|
+
stroke-linecap="round"
|
|
620
|
+
stroke-linejoin="round"
|
|
621
|
+
>
|
|
622
|
+
<rect x="9" y="9" width="13" height="13" rx="2" /><path
|
|
623
|
+
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
624
|
+
/>
|
|
625
|
+
</svg>
|
|
626
|
+
</button>
|
|
627
|
+
<button
|
|
628
|
+
class="icon-btn danger"
|
|
629
|
+
title="Delete run"
|
|
630
|
+
on:click={() => {
|
|
631
|
+
confirmDelete = { type: 'run', id: run.id, name: run.title };
|
|
632
|
+
confirmDeleteOpen = true;
|
|
633
|
+
}}
|
|
634
|
+
>
|
|
635
|
+
<svg
|
|
636
|
+
width="13"
|
|
637
|
+
height="13"
|
|
638
|
+
viewBox="0 0 24 24"
|
|
639
|
+
fill="none"
|
|
640
|
+
stroke="currentColor"
|
|
641
|
+
stroke-width="2"
|
|
642
|
+
stroke-linecap="round"
|
|
643
|
+
stroke-linejoin="round"
|
|
644
|
+
>
|
|
645
|
+
<polyline points="3 6 5 6 21 6" /><path
|
|
646
|
+
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
|
|
647
|
+
/><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
|
648
|
+
</svg>
|
|
649
|
+
</button>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
{/each}
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
{/if}
|
|
656
|
+
{/if}
|
|
657
|
+
</div>
|
|
658
|
+
{:else if tab === 'suites'}
|
|
659
|
+
<div transition:fly={{ y: 4, duration: 160 }}>
|
|
660
|
+
{#if loading}
|
|
661
|
+
<div class="loading-row">Loading…</div>
|
|
662
|
+
{:else if suites.length === 0}
|
|
663
|
+
<EmptyState
|
|
664
|
+
title="No test suites yet"
|
|
665
|
+
description="Create your first suite to start organising test cases."
|
|
666
|
+
/>
|
|
667
|
+
{:else}
|
|
668
|
+
<div class="suite-grid">
|
|
669
|
+
{#each suites as suite (suite.id)}
|
|
670
|
+
<div class="suite-card">
|
|
671
|
+
<a href="/test-repository/suites/{suite.id}" class="card-link" aria-label={suite.name}
|
|
672
|
+
></a>
|
|
673
|
+
<div class="suite-card-header">
|
|
674
|
+
<span class="id-chip">{suite.displayId}</span>
|
|
675
|
+
<span class="priority-badge {priorityClass(suite.priority)}">{suite.priority}</span>
|
|
676
|
+
<div class="suite-card-actions">
|
|
677
|
+
<button
|
|
678
|
+
class="icon-btn danger"
|
|
679
|
+
title="Delete suite"
|
|
680
|
+
on:click={() => {
|
|
681
|
+
confirmDelete = { type: 'suite', id: suite.id, name: suite.name };
|
|
682
|
+
confirmDeleteOpen = true;
|
|
683
|
+
}}
|
|
684
|
+
>
|
|
685
|
+
<svg
|
|
686
|
+
width="13"
|
|
687
|
+
height="13"
|
|
688
|
+
viewBox="0 0 24 24"
|
|
689
|
+
fill="none"
|
|
690
|
+
stroke="currentColor"
|
|
691
|
+
stroke-width="2"
|
|
692
|
+
stroke-linecap="round"
|
|
693
|
+
stroke-linejoin="round"
|
|
694
|
+
>
|
|
695
|
+
<polyline points="3 6 5 6 21 6" /><path
|
|
696
|
+
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
|
|
697
|
+
/><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
|
698
|
+
</svg>
|
|
699
|
+
</button>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
<h3 class="suite-name">{suite.name}</h3>
|
|
703
|
+
{#if suite.description}
|
|
704
|
+
<p class="suite-desc">{suite.description}</p>
|
|
705
|
+
{/if}
|
|
706
|
+
<div class="suite-meta">
|
|
707
|
+
<span class="meta-item">
|
|
708
|
+
<svg
|
|
709
|
+
width="12"
|
|
710
|
+
height="12"
|
|
711
|
+
viewBox="0 0 24 24"
|
|
712
|
+
fill="none"
|
|
713
|
+
stroke="currentColor"
|
|
714
|
+
stroke-width="2"
|
|
715
|
+
><path
|
|
716
|
+
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
|
|
717
|
+
/></svg
|
|
718
|
+
>
|
|
719
|
+
{suite._count.cases} case{suite._count.cases !== 1 ? 's' : ''}
|
|
720
|
+
</span>
|
|
721
|
+
<span class="meta-item">by {suite.createdBy.name}</span>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
{/each}
|
|
725
|
+
</div>
|
|
726
|
+
{#if Math.ceil(suitesTotal / REPO_PAGE_SIZE) > 1}
|
|
727
|
+
<div class="pagination-row">
|
|
728
|
+
<Pagination
|
|
729
|
+
current={suitesPage}
|
|
730
|
+
total={Math.ceil(suitesTotal / REPO_PAGE_SIZE)}
|
|
731
|
+
on:change={(e) => loadSuites(e.detail)}
|
|
732
|
+
/>
|
|
733
|
+
</div>
|
|
734
|
+
{/if}
|
|
735
|
+
{/if}
|
|
736
|
+
</div>
|
|
737
|
+
{:else}
|
|
738
|
+
<div transition:fly={{ y: 4, duration: 160 }}>
|
|
739
|
+
{#if loading}
|
|
740
|
+
<div class="loading-row">Loading…</div>
|
|
741
|
+
{:else if runs.length === 0}
|
|
742
|
+
<EmptyState
|
|
743
|
+
title="No test runs yet"
|
|
744
|
+
description="Create a test run to start executing and tracking manual tests."
|
|
745
|
+
/>
|
|
746
|
+
{:else}
|
|
747
|
+
<div class="runs-list">
|
|
748
|
+
{#each runs as run (run.id)}
|
|
749
|
+
<div class="run-row">
|
|
750
|
+
<a href="/test-repository/runs/{run.id}" class="card-link" aria-label={run.title}></a>
|
|
751
|
+
<div class="run-row-main">
|
|
752
|
+
<span class="run-status-dot {runStatusClass(run.status)}"></span>
|
|
753
|
+
<span class="run-title">{run.title}</span>
|
|
754
|
+
<span class="run-count"
|
|
755
|
+
>{run._count.entries} case{run._count.entries !== 1 ? 's' : ''}</span
|
|
756
|
+
>
|
|
757
|
+
</div>
|
|
758
|
+
<div class="run-meta">
|
|
759
|
+
<span class="run-status-label">{run.status}</span>
|
|
760
|
+
<span class="meta-sep">·</span>
|
|
761
|
+
<span>by {run.createdBy.name}</span>
|
|
762
|
+
<span class="meta-sep">·</span>
|
|
763
|
+
<span>{new Date(run.createdAt).toLocaleDateString()}</span>
|
|
764
|
+
</div>
|
|
765
|
+
<div class="run-row-actions">
|
|
766
|
+
<button
|
|
767
|
+
class="icon-btn"
|
|
768
|
+
title="Duplicate run"
|
|
769
|
+
on:click={() => handleDuplicateRun(run)}
|
|
770
|
+
>
|
|
771
|
+
<svg
|
|
772
|
+
width="13"
|
|
773
|
+
height="13"
|
|
774
|
+
viewBox="0 0 24 24"
|
|
775
|
+
fill="none"
|
|
776
|
+
stroke="currentColor"
|
|
777
|
+
stroke-width="2"
|
|
778
|
+
stroke-linecap="round"
|
|
779
|
+
stroke-linejoin="round"
|
|
780
|
+
>
|
|
781
|
+
<rect x="9" y="9" width="13" height="13" rx="2" /><path
|
|
782
|
+
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
783
|
+
/>
|
|
784
|
+
</svg>
|
|
785
|
+
</button>
|
|
786
|
+
<button
|
|
787
|
+
class="icon-btn danger"
|
|
788
|
+
title="Delete run"
|
|
789
|
+
on:click={() => {
|
|
790
|
+
confirmDelete = { type: 'run', id: run.id, name: run.title };
|
|
791
|
+
confirmDeleteOpen = true;
|
|
792
|
+
}}
|
|
793
|
+
>
|
|
794
|
+
<svg
|
|
795
|
+
width="13"
|
|
796
|
+
height="13"
|
|
797
|
+
viewBox="0 0 24 24"
|
|
798
|
+
fill="none"
|
|
799
|
+
stroke="currentColor"
|
|
800
|
+
stroke-width="2"
|
|
801
|
+
stroke-linecap="round"
|
|
802
|
+
stroke-linejoin="round"
|
|
803
|
+
>
|
|
804
|
+
<polyline points="3 6 5 6 21 6" /><path
|
|
805
|
+
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
|
|
806
|
+
/><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
|
807
|
+
</svg>
|
|
808
|
+
</button>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
{/each}
|
|
812
|
+
</div>
|
|
813
|
+
{#if Math.ceil(runsTotal / REPO_PAGE_SIZE) > 1}
|
|
814
|
+
<div class="pagination-row">
|
|
815
|
+
<Pagination
|
|
816
|
+
current={runsPage}
|
|
817
|
+
total={Math.ceil(runsTotal / REPO_PAGE_SIZE)}
|
|
818
|
+
on:change={(e) => loadRuns(e.detail)}
|
|
819
|
+
/>
|
|
820
|
+
</div>
|
|
821
|
+
{/if}
|
|
822
|
+
{/if}
|
|
823
|
+
</div>
|
|
824
|
+
{/if}
|
|
825
|
+
|
|
826
|
+
<style>
|
|
827
|
+
.page-header {
|
|
828
|
+
display: flex;
|
|
829
|
+
align-items: flex-start;
|
|
830
|
+
justify-content: space-between;
|
|
831
|
+
gap: 1rem;
|
|
832
|
+
margin-bottom: 2rem;
|
|
833
|
+
padding-bottom: 1.5rem;
|
|
834
|
+
border-bottom: 1px solid var(--border);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.header-text h1 {
|
|
838
|
+
font-size: 2.5rem;
|
|
839
|
+
margin-bottom: 0.25rem;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.header-desc {
|
|
843
|
+
font-size: 0.875rem;
|
|
844
|
+
color: var(--text-muted);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.header-actions {
|
|
848
|
+
flex-shrink: 0;
|
|
849
|
+
padding-top: 0.25rem;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/* ── Tabs ── */
|
|
853
|
+
.tabs {
|
|
854
|
+
display: flex;
|
|
855
|
+
gap: 0.125rem;
|
|
856
|
+
margin-bottom: 1.5rem;
|
|
857
|
+
border-bottom: 1px solid var(--border);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.tab {
|
|
861
|
+
display: flex;
|
|
862
|
+
align-items: center;
|
|
863
|
+
gap: 0.5rem;
|
|
864
|
+
padding: 0.5rem 1rem 0.625rem;
|
|
865
|
+
font-family: var(--font-body);
|
|
866
|
+
font-size: 0.875rem;
|
|
867
|
+
font-weight: 400;
|
|
868
|
+
color: var(--text-muted);
|
|
869
|
+
background: none;
|
|
870
|
+
border: none;
|
|
871
|
+
border-bottom: 2px solid transparent;
|
|
872
|
+
cursor: pointer;
|
|
873
|
+
transition:
|
|
874
|
+
color var(--duration-fast),
|
|
875
|
+
border-color var(--duration-fast);
|
|
876
|
+
margin-bottom: -1px;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.tab:hover {
|
|
880
|
+
color: var(--text);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.tab.active {
|
|
884
|
+
color: var(--accent);
|
|
885
|
+
border-bottom-color: var(--accent);
|
|
886
|
+
font-weight: 500;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.tab-count {
|
|
890
|
+
font-size: 0.7rem;
|
|
891
|
+
background: var(--bg-subtle);
|
|
892
|
+
border: 1px solid var(--border);
|
|
893
|
+
border-radius: 100px;
|
|
894
|
+
padding: 0.05rem 0.4rem;
|
|
895
|
+
color: var(--text-muted);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
.loading-row {
|
|
899
|
+
font-size: 0.875rem;
|
|
900
|
+
color: var(--text-muted);
|
|
901
|
+
padding: 2rem 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.pagination-row {
|
|
905
|
+
margin-top: 1.5rem;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* ── Search ── */
|
|
909
|
+
.search-bar {
|
|
910
|
+
position: relative;
|
|
911
|
+
display: flex;
|
|
912
|
+
align-items: center;
|
|
913
|
+
margin-bottom: 1.25rem;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.search-icon {
|
|
917
|
+
position: absolute;
|
|
918
|
+
left: 0.75rem;
|
|
919
|
+
color: var(--text-muted);
|
|
920
|
+
pointer-events: none;
|
|
921
|
+
flex-shrink: 0;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.search-input {
|
|
925
|
+
width: 100%;
|
|
926
|
+
max-width: 360px;
|
|
927
|
+
height: 34px;
|
|
928
|
+
padding: 0 2rem 0 2.25rem;
|
|
929
|
+
font-family: var(--font-body);
|
|
930
|
+
font-size: 0.875rem;
|
|
931
|
+
color: var(--text);
|
|
932
|
+
background: var(--bg-elevated);
|
|
933
|
+
border: 1px solid var(--border);
|
|
934
|
+
border-radius: var(--radius-sm);
|
|
935
|
+
outline: none;
|
|
936
|
+
transition: border-color var(--duration-fast);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.search-input::placeholder {
|
|
940
|
+
color: var(--text-muted);
|
|
941
|
+
}
|
|
942
|
+
.search-input:focus {
|
|
943
|
+
border-color: var(--accent);
|
|
944
|
+
}
|
|
945
|
+
.search-input::-webkit-search-cancel-button {
|
|
946
|
+
display: none;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
.search-clear {
|
|
950
|
+
position: absolute;
|
|
951
|
+
left: calc(360px - 1.75rem);
|
|
952
|
+
display: flex;
|
|
953
|
+
align-items: center;
|
|
954
|
+
justify-content: center;
|
|
955
|
+
width: 20px;
|
|
956
|
+
height: 20px;
|
|
957
|
+
background: none;
|
|
958
|
+
border: none;
|
|
959
|
+
cursor: pointer;
|
|
960
|
+
color: var(--text-muted);
|
|
961
|
+
padding: 0;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.search-clear:hover {
|
|
965
|
+
color: var(--text);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/* ── Sort bar ── */
|
|
969
|
+
.sort-bar {
|
|
970
|
+
display: flex;
|
|
971
|
+
align-items: center;
|
|
972
|
+
gap: 0.375rem;
|
|
973
|
+
margin-bottom: 1.25rem;
|
|
974
|
+
flex-wrap: wrap;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
.sort-label {
|
|
978
|
+
font-size: 0.75rem;
|
|
979
|
+
color: var(--text-muted);
|
|
980
|
+
margin-right: 0.25rem;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.sort-chip {
|
|
984
|
+
display: flex;
|
|
985
|
+
align-items: center;
|
|
986
|
+
gap: 0.25rem;
|
|
987
|
+
height: 28px;
|
|
988
|
+
padding: 0 0.625rem;
|
|
989
|
+
font-family: var(--font-body);
|
|
990
|
+
font-size: 0.8125rem;
|
|
991
|
+
color: var(--text-muted);
|
|
992
|
+
background: var(--bg-elevated);
|
|
993
|
+
border: 1px solid var(--border);
|
|
994
|
+
border-radius: 100px;
|
|
995
|
+
cursor: pointer;
|
|
996
|
+
transition:
|
|
997
|
+
color var(--duration-fast),
|
|
998
|
+
border-color var(--duration-fast),
|
|
999
|
+
background var(--duration-fast);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.sort-chip:hover {
|
|
1003
|
+
color: var(--text);
|
|
1004
|
+
border-color: var(--accent);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.sort-chip.active {
|
|
1008
|
+
color: var(--accent);
|
|
1009
|
+
border-color: var(--accent);
|
|
1010
|
+
background: var(--accent-soft);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.sort-dir {
|
|
1014
|
+
font-size: 0.7rem;
|
|
1015
|
+
opacity: 0.8;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/* ── Search results ── */
|
|
1019
|
+
.search-section {
|
|
1020
|
+
margin-bottom: 2rem;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.search-section-title {
|
|
1024
|
+
display: flex;
|
|
1025
|
+
align-items: center;
|
|
1026
|
+
gap: 0.5rem;
|
|
1027
|
+
font-size: 0.75rem;
|
|
1028
|
+
font-weight: 600;
|
|
1029
|
+
text-transform: uppercase;
|
|
1030
|
+
letter-spacing: 0.06em;
|
|
1031
|
+
color: var(--text-muted);
|
|
1032
|
+
margin-bottom: 0.75rem;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.case-results {
|
|
1036
|
+
display: flex;
|
|
1037
|
+
flex-direction: column;
|
|
1038
|
+
gap: 0.25rem;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.case-result-row {
|
|
1042
|
+
display: flex;
|
|
1043
|
+
align-items: center;
|
|
1044
|
+
gap: 0.625rem;
|
|
1045
|
+
padding: 0.6rem 0.875rem;
|
|
1046
|
+
background: var(--bg-elevated);
|
|
1047
|
+
border: 1px solid var(--border);
|
|
1048
|
+
border-radius: var(--radius-sm);
|
|
1049
|
+
text-decoration: none;
|
|
1050
|
+
color: inherit;
|
|
1051
|
+
transition: border-color var(--duration-fast);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.case-result-row:hover {
|
|
1055
|
+
border-color: var(--accent);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.case-result-title {
|
|
1059
|
+
font-size: 0.875rem;
|
|
1060
|
+
font-weight: 500;
|
|
1061
|
+
color: var(--text);
|
|
1062
|
+
flex: 1;
|
|
1063
|
+
min-width: 0;
|
|
1064
|
+
overflow: hidden;
|
|
1065
|
+
text-overflow: ellipsis;
|
|
1066
|
+
white-space: nowrap;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
.case-result-suite {
|
|
1070
|
+
font-size: 0.75rem;
|
|
1071
|
+
color: var(--text-muted);
|
|
1072
|
+
flex-shrink: 0;
|
|
1073
|
+
white-space: nowrap;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/* ── Suite grid ── */
|
|
1077
|
+
.suite-grid {
|
|
1078
|
+
display: grid;
|
|
1079
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
1080
|
+
gap: 0.875rem;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.card-link {
|
|
1084
|
+
position: absolute;
|
|
1085
|
+
inset: 0;
|
|
1086
|
+
border-radius: inherit;
|
|
1087
|
+
z-index: 0;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.suite-card {
|
|
1091
|
+
position: relative;
|
|
1092
|
+
display: flex;
|
|
1093
|
+
flex-direction: column;
|
|
1094
|
+
gap: 0.5rem;
|
|
1095
|
+
background: var(--bg-elevated);
|
|
1096
|
+
border: 1px solid var(--border);
|
|
1097
|
+
border-top: 3px solid var(--accent);
|
|
1098
|
+
border-radius: var(--radius-md);
|
|
1099
|
+
padding: 1rem;
|
|
1100
|
+
color: inherit;
|
|
1101
|
+
transition:
|
|
1102
|
+
border-color var(--duration-fast),
|
|
1103
|
+
box-shadow var(--duration-fast);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.suite-card:hover {
|
|
1107
|
+
border-color: var(--accent);
|
|
1108
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
.suite-card-header {
|
|
1112
|
+
display: flex;
|
|
1113
|
+
align-items: center;
|
|
1114
|
+
gap: 0.5rem;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
.suite-card-actions {
|
|
1118
|
+
position: relative;
|
|
1119
|
+
z-index: 1;
|
|
1120
|
+
margin-left: auto;
|
|
1121
|
+
opacity: 0;
|
|
1122
|
+
transition: opacity var(--duration-fast);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
.suite-card:hover .suite-card-actions {
|
|
1126
|
+
opacity: 1;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.suite-name {
|
|
1130
|
+
font-size: 0.9375rem;
|
|
1131
|
+
font-weight: 500;
|
|
1132
|
+
color: var(--text);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
.suite-desc {
|
|
1136
|
+
font-size: 0.8125rem;
|
|
1137
|
+
color: var(--text-muted);
|
|
1138
|
+
line-height: 1.5;
|
|
1139
|
+
overflow: hidden;
|
|
1140
|
+
display: -webkit-box;
|
|
1141
|
+
-webkit-line-clamp: 2;
|
|
1142
|
+
-webkit-box-orient: vertical;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
.suite-meta {
|
|
1146
|
+
display: flex;
|
|
1147
|
+
align-items: center;
|
|
1148
|
+
gap: 0.75rem;
|
|
1149
|
+
margin-top: 0.25rem;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
.meta-item {
|
|
1153
|
+
display: flex;
|
|
1154
|
+
align-items: center;
|
|
1155
|
+
gap: 0.3rem;
|
|
1156
|
+
font-size: 0.75rem;
|
|
1157
|
+
color: var(--text-muted);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/* ── ID chip ── */
|
|
1161
|
+
.id-chip {
|
|
1162
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1163
|
+
font-size: 0.7rem;
|
|
1164
|
+
font-weight: 600;
|
|
1165
|
+
color: var(--accent);
|
|
1166
|
+
background: var(--accent-soft);
|
|
1167
|
+
border-radius: 4px;
|
|
1168
|
+
padding: 0.1rem 0.4rem;
|
|
1169
|
+
letter-spacing: 0.02em;
|
|
1170
|
+
flex-shrink: 0;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
.id-chip.small {
|
|
1174
|
+
font-size: 0.65rem;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
.auto-badge {
|
|
1178
|
+
font-size: 0.62rem;
|
|
1179
|
+
font-weight: 600;
|
|
1180
|
+
letter-spacing: 0.04em;
|
|
1181
|
+
text-transform: uppercase;
|
|
1182
|
+
color: var(--pass);
|
|
1183
|
+
background: var(--pass-soft);
|
|
1184
|
+
border-radius: 100px;
|
|
1185
|
+
padding: 0.1rem 0.4rem;
|
|
1186
|
+
flex-shrink: 0;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/* ── Priority badges ── */
|
|
1190
|
+
.priority-badge {
|
|
1191
|
+
font-size: 0.68rem;
|
|
1192
|
+
font-weight: 500;
|
|
1193
|
+
border-radius: 100px;
|
|
1194
|
+
padding: 0.15rem 0.5rem;
|
|
1195
|
+
flex-shrink: 0;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.priority-badge.small {
|
|
1199
|
+
font-size: 0.62rem;
|
|
1200
|
+
padding: 0.1rem 0.4rem;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
.priority-badge.critical {
|
|
1204
|
+
background: var(--fail-soft);
|
|
1205
|
+
color: var(--fail);
|
|
1206
|
+
}
|
|
1207
|
+
.priority-badge.high {
|
|
1208
|
+
background: var(--warn-soft);
|
|
1209
|
+
color: var(--warn);
|
|
1210
|
+
}
|
|
1211
|
+
.priority-badge.medium {
|
|
1212
|
+
background: var(--node-soft);
|
|
1213
|
+
color: var(--node);
|
|
1214
|
+
}
|
|
1215
|
+
.priority-badge.low {
|
|
1216
|
+
background: var(--bg-subtle);
|
|
1217
|
+
color: var(--text-muted);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/* ── Icon button ── */
|
|
1221
|
+
.icon-btn {
|
|
1222
|
+
display: flex;
|
|
1223
|
+
align-items: center;
|
|
1224
|
+
justify-content: center;
|
|
1225
|
+
width: 26px;
|
|
1226
|
+
height: 26px;
|
|
1227
|
+
border: none;
|
|
1228
|
+
border-radius: var(--radius-sm);
|
|
1229
|
+
background: transparent;
|
|
1230
|
+
cursor: pointer;
|
|
1231
|
+
color: var(--text-muted);
|
|
1232
|
+
transition:
|
|
1233
|
+
background var(--duration-fast),
|
|
1234
|
+
color var(--duration-fast);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
.icon-btn.danger:hover {
|
|
1238
|
+
background: var(--fail-soft);
|
|
1239
|
+
color: var(--fail);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/* ── Runs list ── */
|
|
1243
|
+
.runs-list {
|
|
1244
|
+
display: flex;
|
|
1245
|
+
flex-direction: column;
|
|
1246
|
+
gap: 0.5rem;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
.run-row {
|
|
1250
|
+
position: relative;
|
|
1251
|
+
display: flex;
|
|
1252
|
+
align-items: center;
|
|
1253
|
+
gap: 1rem;
|
|
1254
|
+
background: var(--bg-elevated);
|
|
1255
|
+
border: 1px solid var(--border);
|
|
1256
|
+
border-radius: var(--radius-md);
|
|
1257
|
+
padding: 0.875rem 1rem;
|
|
1258
|
+
color: inherit;
|
|
1259
|
+
transition: border-color var(--duration-fast);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
.run-row:hover {
|
|
1263
|
+
border-color: var(--accent);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.run-row-main {
|
|
1267
|
+
display: flex;
|
|
1268
|
+
align-items: center;
|
|
1269
|
+
gap: 0.625rem;
|
|
1270
|
+
flex: 1;
|
|
1271
|
+
min-width: 0;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.run-status-dot {
|
|
1275
|
+
width: 8px;
|
|
1276
|
+
height: 8px;
|
|
1277
|
+
border-radius: 50%;
|
|
1278
|
+
flex-shrink: 0;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
.run-status-dot.pass {
|
|
1282
|
+
background: var(--pass);
|
|
1283
|
+
}
|
|
1284
|
+
.run-status-dot.warn {
|
|
1285
|
+
background: var(--warn);
|
|
1286
|
+
}
|
|
1287
|
+
.run-status-dot.muted {
|
|
1288
|
+
background: var(--border);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.run-title {
|
|
1292
|
+
font-size: 0.9375rem;
|
|
1293
|
+
font-weight: 500;
|
|
1294
|
+
color: var(--text);
|
|
1295
|
+
min-width: 0;
|
|
1296
|
+
overflow: hidden;
|
|
1297
|
+
text-overflow: ellipsis;
|
|
1298
|
+
white-space: nowrap;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.run-count {
|
|
1302
|
+
font-size: 0.75rem;
|
|
1303
|
+
color: var(--text-muted);
|
|
1304
|
+
flex-shrink: 0;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
.run-meta {
|
|
1308
|
+
display: flex;
|
|
1309
|
+
align-items: center;
|
|
1310
|
+
gap: 0.375rem;
|
|
1311
|
+
font-size: 0.75rem;
|
|
1312
|
+
color: var(--text-muted);
|
|
1313
|
+
flex-shrink: 0;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
.run-status-label {
|
|
1317
|
+
font-size: 0.7rem;
|
|
1318
|
+
font-weight: 500;
|
|
1319
|
+
text-transform: capitalize;
|
|
1320
|
+
color: var(--text-muted);
|
|
1321
|
+
background: var(--bg-subtle);
|
|
1322
|
+
border: 1px solid var(--border);
|
|
1323
|
+
border-radius: 100px;
|
|
1324
|
+
padding: 0.1rem 0.4rem;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
.meta-sep {
|
|
1328
|
+
color: var(--border);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
.run-row-actions {
|
|
1332
|
+
position: relative;
|
|
1333
|
+
z-index: 1;
|
|
1334
|
+
opacity: 0;
|
|
1335
|
+
transition: opacity var(--duration-fast);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
.run-row:hover .run-row-actions {
|
|
1339
|
+
opacity: 1;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/* ── Form helpers ── */
|
|
1343
|
+
.form-fields {
|
|
1344
|
+
display: flex;
|
|
1345
|
+
flex-direction: column;
|
|
1346
|
+
gap: 1rem;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
.field-textarea {
|
|
1350
|
+
height: auto;
|
|
1351
|
+
resize: vertical;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.form-error {
|
|
1355
|
+
font-size: 0.8125rem;
|
|
1356
|
+
color: var(--fail);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
.modal-actions {
|
|
1360
|
+
display: flex;
|
|
1361
|
+
gap: 0.5rem;
|
|
1362
|
+
padding-top: 0.25rem;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
@media (max-width: 640px) {
|
|
1366
|
+
.page-header {
|
|
1367
|
+
flex-direction: column;
|
|
1368
|
+
gap: 0.75rem;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.suite-grid {
|
|
1372
|
+
grid-template-columns: 1fr;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
.run-meta {
|
|
1376
|
+
display: none;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
</style>
|