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.
Files changed (47) 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/runnerService.js +16 -1
  22. package/backend/services/settingsService.js +18 -2
  23. package/backend/services/testCaseService.js +139 -0
  24. package/backend/services/testRunService.js +203 -0
  25. package/backend/services/testSuiteService.js +191 -0
  26. package/backend/services/userService.js +114 -0
  27. package/backend/websockets/socketHandler.js +19 -6
  28. package/bin/plum.js +105 -9
  29. package/frontend/src/lib/api/auth.js +69 -0
  30. package/frontend/src/lib/api/repository.js +256 -0
  31. package/frontend/src/lib/api/users.js +52 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  33. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  34. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  35. package/frontend/src/lib/constants.js +2 -0
  36. package/frontend/src/lib/stores/auth.js +60 -0
  37. package/frontend/src/lib/stores/runner.js +9 -2
  38. package/frontend/src/routes/+layout.svelte +32 -4
  39. package/frontend/src/routes/+page.svelte +1 -1
  40. package/frontend/src/routes/login/+page.svelte +209 -0
  41. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -1
  42. package/frontend/src/routes/settings/+page.svelte +586 -5
  43. package/frontend/src/routes/setup/+page.svelte +249 -0
  44. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  45. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  46. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  47. 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 &ldquo;{search}&rdquo;." />
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>