plum-e2e 1.0.10 → 1.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 (51) hide show
  1. package/.claude/settings.local.json +16 -1
  2. package/.vscode/settings.json +10 -0
  3. package/README.md +151 -37
  4. package/backend/_scaffold/features/LoginPage.feature +45 -3
  5. package/backend/_scaffold/pages/HomepagePage.ts +7 -0
  6. package/backend/_scaffold/pages/LoginPage.ts +37 -13
  7. package/backend/_scaffold/step_definitions/HomepageSteps.ts +6 -0
  8. package/backend/_scaffold/step_definitions/LoginSteps.ts +30 -4
  9. package/backend/_scaffold/utils/browser.ts +33 -0
  10. package/backend/_scaffold/utils/hooks.ts +8 -29
  11. package/backend/_scaffold/utils/utils.ts +3 -9
  12. package/backend/config/scripts/create-settings.js +7 -14
  13. package/backend/config/scripts/create-step.mjs +268 -0
  14. package/backend/config/scripts/generate-report.js +31 -75
  15. package/backend/config/scripts/run-tests.js +19 -4
  16. package/backend/package-lock.json +56 -641
  17. package/backend/package.json +4 -1
  18. package/backend/routes/reports.routes.js +6 -10
  19. package/backend/services/envService.js +4 -10
  20. package/backend/services/reportService.js +70 -20
  21. package/backend/services/testService.js +99 -24
  22. package/backend/tsconfig.json +2 -2
  23. package/backend/websockets/socketHandler.js +12 -6
  24. package/bin/plum.js +49 -3
  25. package/frontend/package-lock.json +436 -135
  26. package/frontend/package.json +1 -1
  27. package/frontend/src/app.css +241 -6
  28. package/frontend/src/app.html +14 -1
  29. package/frontend/src/lib/api/reports.js +68 -0
  30. package/frontend/src/lib/api/schedules.js +64 -0
  31. package/frontend/src/lib/api/tests.js +41 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +304 -0
  33. package/frontend/src/lib/components/layout/PageShell.svelte +28 -0
  34. package/frontend/src/lib/components/layout/RunnerPanel.svelte +378 -0
  35. package/frontend/src/lib/components/ui/Badge.svelte +63 -0
  36. package/frontend/src/lib/components/ui/Button.svelte +117 -0
  37. package/frontend/src/lib/components/ui/Modal.svelte +140 -0
  38. package/frontend/src/lib/components/ui/Pagination.svelte +100 -0
  39. package/frontend/src/lib/components/ui/Terminal.svelte +100 -0
  40. package/frontend/src/lib/stores/runner.js +55 -0
  41. package/frontend/src/lib/stores/theme.js +47 -0
  42. package/frontend/src/routes/+layout.svelte +7 -12
  43. package/frontend/src/routes/+page.svelte +690 -142
  44. package/frontend/src/routes/reports/+page.svelte +395 -125
  45. package/frontend/src/routes/reports/[slug]/+page.svelte +749 -0
  46. package/frontend/src/routes/scheduled-tests/+page.svelte +267 -303
  47. package/frontend/svelte.config.js +1 -4
  48. package/frontend/tailwind.config.js +2 -23
  49. package/package.json +2 -2
  50. package/backend/_scaffold/utils/world.ts +0 -25
  51. package/frontend/src/routes/components/Navigation.svelte +0 -53
@@ -16,173 +16,721 @@
16
16
  -->
17
17
 
18
18
  <script>
19
- import { onMount } from 'svelte';
20
- import { io } from 'socket.io-client';
21
-
22
- let output = "Enter a test ID and click 'Run Test'...\n";
23
- let socket;
24
- let testCompleted = false;
25
- let testID = '';
26
- let outputRef;
19
+ import { onDestroy, onMount } from 'svelte';
20
+ import { slide } from 'svelte/transition';
21
+ import { fetchSuites } from '$lib/api/tests';
22
+ import { runnerConfig, triggerRun } from '$lib/stores/runner';
23
+
27
24
  let suites = [];
28
- let latestReport = null;
25
+ let search = '';
26
+ let expandedSteps = new Set();
27
+ let copiedIds = new Set();
28
+ const copyTimers = new Map();
29
29
 
30
30
  onMount(async () => {
31
- socket = io('http://localhost:3001');
32
-
33
- socket.on('log', (data) => {
34
- output += data + '\n';
35
- scrollToBottom();
36
- });
31
+ try {
32
+ suites = await fetchSuites();
33
+ } catch (e) {
34
+ console.error('Failed to fetch suites', e);
35
+ }
36
+ });
37
37
 
38
- socket.on('done', async () => {
39
- output += '✅ Test completed!\n';
40
- testCompleted = true;
41
- scrollToBottom();
38
+ function suiteIds(suite) {
39
+ return Array.isArray(suite.suiteId) ? suite.suiteId : [suite.suiteId];
40
+ }
42
41
 
43
- // Fetch the latest report **after** test completion
44
- await fetchLatestReport();
45
- });
42
+ function testIds(test) {
43
+ return Array.isArray(test.id) ? test.id : [test.id];
44
+ }
46
45
 
47
- // Fetch test names from backend
48
- try {
49
- const response = await fetch('http://localhost:3001/tests');
50
- const data = await response.json();
46
+ function primaryId(test) {
47
+ return Array.isArray(test.id) ? test.id[0] : test.id;
48
+ }
51
49
 
52
- suites = data.suites.suites;
53
- } catch (error) {
54
- console.error('Error fetching test suites:', error);
50
+ function toggleSteps(id) {
51
+ if (expandedSteps.has(id)) {
52
+ expandedSteps.delete(id);
53
+ } else {
54
+ expandedSteps.add(id);
55
55
  }
56
- });
57
-
58
- function runTest() {
59
- const formattedTestID = testID.replace(/\sOR\s/gi, (match) => match.toLowerCase());
60
- output = `Running test with ID: ${formattedTestID}...\n`;
61
- testCompleted = false;
62
- socket.emit('run-test', formattedTestID, 'manual-trigger');
56
+ expandedSteps = expandedSteps;
63
57
  }
64
58
 
65
- async function fetchLatestReport() {
66
- const response = await fetch('http://localhost:3001/reports/latest');
67
- const data = await response.json();
68
- latestReport = data.latestReport;
59
+ function run(id) {
60
+ runnerConfig.update((c) => ({ ...c, testID: id }));
61
+ triggerRun(id);
69
62
  }
70
63
 
71
- async function scrollToBottom() {
72
- await new Promise((resolve) => setTimeout(resolve, 50));
73
- if (outputRef) outputRef.scrollTop = outputRef.scrollHeight;
64
+ function runSuite(suite) {
65
+ const id = suiteIds(suite)[0];
66
+ run(id);
74
67
  }
75
68
 
76
- function copyIdToTextbox(id) {
77
- console.log('Selected Test ID:', id);
78
- testID = id;
69
+ async function copyId(id) {
70
+ await navigator.clipboard.writeText(id);
71
+ copiedIds.add(id);
72
+ copiedIds = copiedIds;
73
+
74
+ if (copyTimers.has(id)) clearTimeout(copyTimers.get(id));
75
+ copyTimers.set(
76
+ id,
77
+ setTimeout(() => {
78
+ copiedIds.delete(id);
79
+ copiedIds = copiedIds;
80
+ copyTimers.delete(id);
81
+ }, 1400)
82
+ );
79
83
  }
84
+
85
+ onDestroy(() => {
86
+ for (const timer of copyTimers.values()) clearTimeout(timer);
87
+ });
88
+
89
+ $: q = search.trim().toLowerCase();
90
+ $: filtered = suites
91
+ .map((suite) => {
92
+ if (!q) return suite;
93
+ const suiteMatches = suite.suiteName.toLowerCase().includes(q);
94
+ const matchedTests = suite.tests.filter(
95
+ (t) =>
96
+ t.testCase.toLowerCase().includes(q) ||
97
+ testIds(t).some((id) => id.toLowerCase().includes(q))
98
+ );
99
+ if (!suiteMatches && matchedTests.length === 0) return null;
100
+ return { ...suite, tests: suiteMatches ? suite.tests : matchedTests };
101
+ })
102
+ .filter(Boolean);
103
+
104
+ $: totalTests = suites.reduce((n, s) => n + s.tests.length, 0);
105
+ $: visibleTests = filtered.reduce((n, s) => n + s.tests.length, 0);
80
106
  </script>
81
107
 
82
- <div class="flex flex-col md:flex-row w-full my-4">
83
- <!-- Left Panel: Run Tests -->
84
- <div
85
- class="card bg-base-300 rounded-box grid flex-grow place-items-center md:w-1/2 w-full p-4 md:mr-2 md:ml-4 mb-4 md:mb-0"
86
- >
87
- <div class="card-body items-center text-center">
88
- <h2 class="card-title">Run Tests</h2>
89
- <p>Enter a test case/suite ID or select an ID from the Test List</p>
90
- <label class="form-control w-full max-w-xs mt-4">
91
- <input
92
- type="text"
93
- class="input input-bordered w-full max-w-xs"
94
- bind:value={testID}
95
- placeholder="Enter test ID"
96
- />
97
- </label>
98
- <div class="card-actions justify-end">
99
- <button class="btn btn-primary" on:click={runTest}>Run</button>
100
- {#if testCompleted}
101
- <a href={`http://localhost:3001/reports/${latestReport}`} target="_blank">
102
- <button class="btn btn-primary">View Report</button>
103
- </a>
108
+ <div class="page-header">
109
+ <div class="header-top">
110
+ <div>
111
+ <h1>Tests</h1>
112
+ <p class="subtitle">
113
+ {#if q}
114
+ {visibleTests} of {totalTests} tests
115
+ {:else}
116
+ {suites.length} suite{suites.length !== 1 ? 's' : ''} · {totalTests} test{totalTests !== 1
117
+ ? 's'
118
+ : ''}
104
119
  {/if}
105
- </div>
120
+ </p>
106
121
  </div>
122
+ </div>
107
123
 
108
- <pre
109
- bind:this={outputRef}
110
- class="bg-black rounded-box p-4 w-full overflow-auto whitespace-pre-wrap h-64 max-h-64">{output}</pre>
124
+ <div class="search-row">
125
+ <div class="search-wrap">
126
+ <svg
127
+ class="search-icon"
128
+ width="14"
129
+ height="14"
130
+ viewBox="0 0 24 24"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ stroke-width="2"
134
+ stroke-linecap="round"
135
+ stroke-linejoin="round"
136
+ >
137
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
138
+ </svg>
139
+ <input
140
+ type="text"
141
+ class="search-input"
142
+ bind:value={search}
143
+ placeholder="Search suites or tests…"
144
+ />
145
+ {#if search}
146
+ <button class="search-clear" on:click={() => (search = '')} aria-label="Clear search">
147
+ <svg width="12" height="12" viewBox="0 0 14 14" fill="none">
148
+ <path
149
+ d="M1 1l12 12M13 1L1 13"
150
+ stroke="currentColor"
151
+ stroke-width="1.5"
152
+ stroke-linecap="round"
153
+ />
154
+ </svg>
155
+ </button>
156
+ {/if}
157
+ </div>
111
158
  </div>
159
+ </div>
112
160
 
113
- <!-- Right Panel: Test List -->
114
- <div class="card bg-base-300 rounded-box md:w-1/2 w-full p-4 md:ml-2 md:mr-4">
115
- <div class="card-body items-center text-center">
116
- <h2 class="card-title sticky top-0 bg-base-300 z-10">Test List</h2>
117
- <div class="mt-4">
118
- {#each suites as suite, suiteIndex}
119
- <div class="collapse bg-base-200 mb-4">
120
- <input type="radio" name="my-accordion-1" id="collapse-{suiteIndex}" />
121
- <label
122
- for="collapse-{suiteIndex}"
123
- class="collapse-title font-medium justify-start cursor-pointer"
161
+ {#if filtered.length === 0}
162
+ <p class="empty">
163
+ {q ? `No tests matching "${search}"` : 'No test suites found.'}
164
+ </p>
165
+ {:else}
166
+ <div class="suites">
167
+ {#each filtered as suite, si}
168
+ <div class="suite" style="animation-delay: {si * 55}ms">
169
+ <div class="suite-header">
170
+ <div class="suite-meta">
171
+ <div class="suite-badges">
172
+ {#each suiteIds(suite) as id}
173
+ <button
174
+ class="id-pill"
175
+ class:copied={copiedIds.has(id)}
176
+ on:click={() => copyId(id)}
177
+ title={copiedIds.has(id) ? `Copied ${id}` : `Copy ${id}`}
178
+ aria-label={copiedIds.has(id) ? `Copied ${id}` : `Copy ${id}`}
179
+ >
180
+ {id}
181
+ </button>
182
+ {/each}
183
+ </div>
184
+ <span class="suite-name">{suite.suiteName}</span>
185
+ <span class="suite-count"
186
+ >{suite.tests.length} test{suite.tests.length !== 1 ? 's' : ''}</span
124
187
  >
125
- {#if Array.isArray(suite.suiteId)}
126
- {#each suite.suiteId as suiteId}
127
- <div class="badge badge-primary mr-2">{suiteId}</div>
128
- {/each}
129
- {:else}
130
- <div class="badge badge-primary mr-2">{suite.suiteId}</div>
131
- {/if}
132
- {suite.suiteName}
133
- </label>
134
-
135
- <div class="collapse-content">
136
- <button
137
- class="btn btn-active btn-ghost btn-xs"
138
- on:click={() =>
139
- copyIdToTextbox(Array.isArray(suite.suiteId) ? suite.suiteId[0] : suite.suiteId)}
140
- >
141
- Select Suite
142
- </button>
143
-
144
- <div class="card rounded-lg shadow-md mt-2">
145
- <div class="overflow-x-auto">
146
- <table class="table">
147
- <thead>
148
- <tr>
149
- <th>ID</th>
150
- <th>Test Case</th>
151
- </tr>
152
- </thead>
153
- <tbody>
154
- {#each suite.tests as test}
155
- <tr>
156
- <th>
157
- {#if Array.isArray(test.id)}
158
- {#each test.id as testId}
159
- <button
160
- class="btn btn-active btn-ghost btn-xs mr-1"
161
- on:click={() => copyIdToTextbox(testId)}
162
- >
163
- {testId}
164
- </button>
165
- {/each}
166
- {:else}
167
- <button
168
- class="btn btn-active btn-ghost btn-xs"
169
- on:click={() => copyIdToTextbox(test.id)}
170
- >
171
- {test.id}
172
- </button>
173
- {/if}
174
- </th>
175
- <td>{test.testCase}</td>
176
- </tr>
177
- {/each}
178
- </tbody>
179
- </table>
188
+ </div>
189
+ <button class="run-btn suite-run" on:click={() => runSuite(suite)} title="Run suite">
190
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none">
191
+ <polygon points="5,3 19,12 5,21" />
192
+ </svg>
193
+ Run suite
194
+ </button>
195
+ </div>
196
+
197
+ <div class="tests">
198
+ {#each suite.tests as test, ti}
199
+ {@const pid = primaryId(test)}
200
+ {@const stepsOpen = expandedSteps.has(pid)}
201
+ <div class="test-row" style="animation-delay: {(si * 4 + ti) * 30}ms">
202
+ <div class="test-main">
203
+ <button
204
+ class="run-icon-btn"
205
+ on:click={() => run(pid)}
206
+ title="Run {pid}"
207
+ aria-label="Run {test.testCase}"
208
+ >
209
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none">
210
+ <polygon points="5,3 19,12 5,21" />
211
+ </svg>
212
+ </button>
213
+
214
+ <div class="test-ids">
215
+ {#each testIds(test) as id}
216
+ <button
217
+ class="id-pill"
218
+ class:copied={copiedIds.has(id)}
219
+ on:click={() => copyId(id)}
220
+ title={copiedIds.has(id) ? `Copied ${id}` : `Copy ${id}`}
221
+ aria-label={copiedIds.has(id) ? `Copied ${id}` : `Copy ${id}`}
222
+ >
223
+ {id}
224
+ </button>
225
+ {/each}
180
226
  </div>
227
+
228
+ <span class="test-name">
229
+ {test.testCase}
230
+ {#if test.type === 'outline'}
231
+ <span class="outline-badge">outline</span>
232
+ {/if}
233
+ </span>
234
+
235
+ {#if test.steps?.length > 0}
236
+ <button
237
+ class="steps-toggle"
238
+ on:click={() => toggleSteps(pid)}
239
+ aria-expanded={stepsOpen}
240
+ >
241
+ <svg
242
+ width="12"
243
+ height="12"
244
+ viewBox="0 0 24 24"
245
+ fill="none"
246
+ stroke="currentColor"
247
+ stroke-width="2"
248
+ stroke-linecap="round"
249
+ class:rotated={stepsOpen}
250
+ >
251
+ <polyline points="9 18 15 12 9 6" />
252
+ </svg>
253
+ {stepsOpen ? 'Hide' : 'Steps'}
254
+ </button>
255
+ {/if}
181
256
  </div>
257
+
258
+ {#if stepsOpen && test.steps?.length > 0}
259
+ <div class="steps" transition:slide={{ duration: 180 }}>
260
+ <ol class="step-list">
261
+ {#each test.steps as step}
262
+ <li class="step">{step}</li>
263
+ {/each}
264
+ </ol>
265
+
266
+ {#if test.examples}
267
+ <div class="examples">
268
+ <span class="examples-label">Examples</span>
269
+ <div class="examples-table-wrap">
270
+ <table class="examples-table">
271
+ <thead>
272
+ <tr>
273
+ {#each test.examples.headers as h}
274
+ <th>{h}</th>
275
+ {/each}
276
+ </tr>
277
+ </thead>
278
+ <tbody>
279
+ {#each test.examples.rows as row}
280
+ <tr>
281
+ {#each row as cell}
282
+ <td>{cell}</td>
283
+ {/each}
284
+ </tr>
285
+ {/each}
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+ </div>
290
+ {/if}
291
+ </div>
292
+ {/if}
182
293
  </div>
183
- </div>
184
- {/each}
294
+ {/each}
295
+ </div>
185
296
  </div>
186
- </div>
297
+ {/each}
187
298
  </div>
188
- </div>
299
+ {/if}
300
+
301
+ <style>
302
+ .page-header {
303
+ margin-bottom: 1.75rem;
304
+ padding-bottom: 1.5rem;
305
+ border-bottom: 1px solid var(--border);
306
+ }
307
+
308
+ .header-top {
309
+ display: flex;
310
+ align-items: flex-end;
311
+ justify-content: space-between;
312
+ gap: 1rem;
313
+ margin-bottom: 1.25rem;
314
+ }
315
+
316
+ h1 {
317
+ font-size: 2.5rem;
318
+ margin-bottom: 0.25rem;
319
+ }
320
+
321
+ .subtitle {
322
+ color: var(--text-muted);
323
+ font-size: 0.875rem;
324
+ }
325
+
326
+ /* ── Search ── */
327
+ .search-row {
328
+ display: flex;
329
+ gap: 0.75rem;
330
+ }
331
+
332
+ .search-wrap {
333
+ position: relative;
334
+ flex: 1;
335
+ max-width: 480px;
336
+ }
337
+
338
+ .search-icon {
339
+ position: absolute;
340
+ left: 0.75rem;
341
+ top: 50%;
342
+ transform: translateY(-50%);
343
+ color: var(--text-muted);
344
+ pointer-events: none;
345
+ }
346
+
347
+ .search-input {
348
+ width: 100%;
349
+ padding: 0.55rem 0.875rem 0.55rem 2.25rem;
350
+ border: 1px solid var(--border);
351
+ border-radius: var(--radius-md);
352
+ background: var(--bg-subtle);
353
+ color: var(--text);
354
+ font-family: var(--font-body);
355
+ font-size: 0.875rem;
356
+ font-weight: 300;
357
+ outline: none;
358
+ transition:
359
+ border-color var(--duration-fast),
360
+ box-shadow var(--duration-fast);
361
+ }
362
+
363
+ .search-input:focus {
364
+ border-color: var(--accent);
365
+ box-shadow: 0 0 0 3px var(--accent-soft);
366
+ background: var(--bg-elevated);
367
+ }
368
+
369
+ .search-input::placeholder {
370
+ color: var(--text-muted);
371
+ }
372
+
373
+ .search-clear {
374
+ position: absolute;
375
+ right: 0.625rem;
376
+ top: 50%;
377
+ transform: translateY(-50%);
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ width: 20px;
382
+ height: 20px;
383
+ border-radius: 50%;
384
+ border: none;
385
+ background: var(--border);
386
+ color: var(--text-muted);
387
+ cursor: pointer;
388
+ transition:
389
+ background var(--duration-fast),
390
+ color var(--duration-fast);
391
+ }
392
+
393
+ .search-clear:hover {
394
+ background: var(--text-muted);
395
+ color: var(--bg);
396
+ }
397
+
398
+ /* ── Suites ── */
399
+ .suites {
400
+ display: flex;
401
+ flex-direction: column;
402
+ gap: 1rem;
403
+ }
404
+
405
+ .suite {
406
+ border: 1px solid var(--border);
407
+ border-radius: var(--radius-lg);
408
+ overflow: visible;
409
+ background: var(--bg-elevated);
410
+ animation: fadeUp 0.35s var(--ease-out) both;
411
+ }
412
+
413
+ .suite-header {
414
+ display: flex;
415
+ align-items: center;
416
+ justify-content: space-between;
417
+ gap: 1rem;
418
+ padding: 0.875rem 1.25rem;
419
+ background: var(--bg-subtle);
420
+ border-bottom: 1px solid var(--border);
421
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
422
+ }
423
+
424
+ .suite-meta {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 0.625rem;
428
+ flex: 1;
429
+ min-width: 0;
430
+ }
431
+
432
+ .suite-badges {
433
+ display: flex;
434
+ gap: 0.25rem;
435
+ flex-shrink: 0;
436
+ }
437
+
438
+ .suite-name {
439
+ font-size: 0.9rem;
440
+ font-weight: 400;
441
+ color: var(--text);
442
+ white-space: nowrap;
443
+ overflow: hidden;
444
+ text-overflow: ellipsis;
445
+ }
446
+
447
+ .suite-count {
448
+ font-size: 0.75rem;
449
+ color: var(--text-muted);
450
+ flex-shrink: 0;
451
+ }
452
+
453
+ .run-btn {
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: 0.35rem;
457
+ font-family: var(--font-body);
458
+ font-size: 0.75rem;
459
+ font-weight: 400;
460
+ padding: 0.3rem 0.75rem;
461
+ border: 1px solid var(--border);
462
+ border-radius: var(--radius-sm);
463
+ background: var(--bg-elevated);
464
+ color: var(--text-muted);
465
+ cursor: pointer;
466
+ white-space: nowrap;
467
+ flex-shrink: 0;
468
+ transition:
469
+ background var(--duration-fast),
470
+ color var(--duration-fast),
471
+ border-color var(--duration-fast);
472
+ }
473
+
474
+ .run-btn:hover {
475
+ background: var(--accent-soft);
476
+ color: var(--accent);
477
+ border-color: var(--accent);
478
+ }
479
+
480
+ /* ── Tests ── */
481
+ .tests {
482
+ display: flex;
483
+ flex-direction: column;
484
+ }
485
+
486
+ .test-row {
487
+ border-bottom: 1px solid var(--border);
488
+ animation: fadeUp 0.3s var(--ease-out) both;
489
+ }
490
+
491
+ .test-row:last-child {
492
+ border-bottom: none;
493
+ }
494
+
495
+ .test-main {
496
+ display: flex;
497
+ align-items: center;
498
+ gap: 0.625rem;
499
+ padding: 0.75rem 1.25rem;
500
+ }
501
+
502
+ .run-icon-btn {
503
+ display: flex;
504
+ align-items: center;
505
+ justify-content: center;
506
+ width: 24px;
507
+ height: 24px;
508
+ border-radius: 50%;
509
+ border: 1px solid var(--border);
510
+ background: transparent;
511
+ color: var(--text-muted);
512
+ cursor: pointer;
513
+ flex-shrink: 0;
514
+ transition:
515
+ background var(--duration-fast),
516
+ color var(--duration-fast),
517
+ border-color var(--duration-fast);
518
+ }
519
+
520
+ .run-icon-btn:hover {
521
+ background: var(--accent);
522
+ color: #fff;
523
+ border-color: var(--accent);
524
+ }
525
+
526
+ .test-ids {
527
+ display: flex;
528
+ gap: 0.25rem;
529
+ flex-shrink: 0;
530
+ }
531
+
532
+ .id-pill {
533
+ position: relative;
534
+ font-size: 0.68rem;
535
+ font-weight: 500;
536
+ letter-spacing: 0.04em;
537
+ color: var(--accent);
538
+ background: var(--accent-soft);
539
+ border: none;
540
+ border-radius: 100px;
541
+ padding: 0.15rem 0.55rem;
542
+ cursor: pointer;
543
+ font-family: 'JetBrains Mono', monospace;
544
+ transition:
545
+ background var(--duration-fast),
546
+ color var(--duration-fast),
547
+ filter var(--duration-fast);
548
+ }
549
+
550
+ .id-pill:hover {
551
+ filter: brightness(0.92);
552
+ }
553
+
554
+ .id-pill.copied {
555
+ color: var(--pass);
556
+ background: var(--pass-soft);
557
+ }
558
+
559
+ .id-pill.copied::after {
560
+ content: 'Copied';
561
+ position: absolute;
562
+ left: 50%;
563
+ bottom: calc(100% + 6px);
564
+ z-index: 50;
565
+ transform: translateX(-50%);
566
+ padding: 0.16rem 0.42rem;
567
+ border: 1px solid var(--border);
568
+ border-radius: var(--radius-sm);
569
+ background: var(--bg-elevated);
570
+ color: var(--pass);
571
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
572
+ font-family: var(--font-body);
573
+ font-size: 0.65rem;
574
+ font-weight: 500;
575
+ letter-spacing: 0;
576
+ pointer-events: none;
577
+ animation: copiedPop 1.4s var(--ease-out) both;
578
+ }
579
+
580
+ .test-name {
581
+ flex: 1;
582
+ font-size: 0.875rem;
583
+ color: var(--text);
584
+ min-width: 0;
585
+ }
586
+
587
+ .outline-badge {
588
+ display: inline-block;
589
+ font-size: 0.62rem;
590
+ font-weight: 500;
591
+ letter-spacing: 0.06em;
592
+ text-transform: uppercase;
593
+ background: var(--warn-soft);
594
+ color: var(--warn);
595
+ padding: 0.1rem 0.4rem;
596
+ border-radius: 100px;
597
+ margin-left: 0.4rem;
598
+ vertical-align: middle;
599
+ }
600
+
601
+ .steps-toggle {
602
+ display: inline-flex;
603
+ align-items: center;
604
+ gap: 0.2rem;
605
+ font-family: var(--font-body);
606
+ font-size: 0.72rem;
607
+ font-weight: 400;
608
+ color: var(--text-muted);
609
+ background: transparent;
610
+ border: 1px solid var(--border);
611
+ border-radius: var(--radius-sm);
612
+ padding: 0.2rem 0.5rem;
613
+ cursor: pointer;
614
+ flex-shrink: 0;
615
+ transition:
616
+ background var(--duration-fast),
617
+ color var(--duration-fast);
618
+ }
619
+
620
+ .steps-toggle:hover {
621
+ background: var(--bg-subtle);
622
+ color: var(--text);
623
+ }
624
+
625
+ .steps-toggle svg {
626
+ transition: transform var(--duration-fast) var(--ease-out);
627
+ }
628
+
629
+ .steps-toggle svg.rotated {
630
+ transform: rotate(90deg);
631
+ }
632
+
633
+ /* ── Steps panel ── */
634
+ .steps {
635
+ padding: 0.75rem 1.25rem 0.875rem 3.75rem;
636
+ background: var(--bg-subtle);
637
+ border-top: 1px solid var(--border);
638
+ }
639
+
640
+ .step-list {
641
+ list-style: none;
642
+ display: flex;
643
+ flex-direction: column;
644
+ gap: 0.3rem;
645
+ }
646
+
647
+ .step {
648
+ font-size: 0.8125rem;
649
+ color: var(--text-muted);
650
+ font-family: 'JetBrains Mono', monospace;
651
+ line-height: 1.5;
652
+ padding-left: 1rem;
653
+ position: relative;
654
+ }
655
+
656
+ .step::before {
657
+ content: '›';
658
+ position: absolute;
659
+ left: 0;
660
+ color: var(--accent);
661
+ font-family: var(--font-body);
662
+ }
663
+
664
+ /* ── Examples table ── */
665
+ .examples {
666
+ margin-top: 0.75rem;
667
+ padding-top: 0.75rem;
668
+ border-top: 1px dashed var(--border);
669
+ }
670
+
671
+ .examples-label {
672
+ display: block;
673
+ font-size: 0.68rem;
674
+ font-weight: 500;
675
+ letter-spacing: 0.07em;
676
+ text-transform: uppercase;
677
+ color: var(--text-muted);
678
+ margin-bottom: 0.5rem;
679
+ }
680
+
681
+ .examples-table-wrap {
682
+ overflow-x: auto;
683
+ }
684
+
685
+ .examples-table {
686
+ border-collapse: collapse;
687
+ font-size: 0.78rem;
688
+ font-family: 'JetBrains Mono', monospace;
689
+ width: auto;
690
+ }
691
+
692
+ .examples-table th {
693
+ text-align: left;
694
+ padding: 0.3rem 0.75rem;
695
+ font-size: 0.7rem;
696
+ letter-spacing: 0.06em;
697
+ text-transform: uppercase;
698
+ color: var(--text-muted);
699
+ border-bottom: 1px solid var(--border);
700
+ font-family: var(--font-body);
701
+ font-weight: 500;
702
+ }
703
+
704
+ .examples-table td {
705
+ padding: 0.3rem 0.75rem;
706
+ color: var(--text-muted);
707
+ border-bottom: 1px solid var(--border);
708
+ }
709
+
710
+ .examples-table tbody tr:last-child td {
711
+ border-bottom: none;
712
+ }
713
+
714
+ .empty {
715
+ color: var(--text-muted);
716
+ font-size: 0.9375rem;
717
+ padding: 3rem 0;
718
+ text-align: center;
719
+ }
720
+
721
+ @keyframes copiedPop {
722
+ 0% {
723
+ opacity: 0;
724
+ transform: translate(-50%, 4px) scale(0.96);
725
+ }
726
+ 12%,
727
+ 78% {
728
+ opacity: 1;
729
+ transform: translate(-50%, 0) scale(1);
730
+ }
731
+ 100% {
732
+ opacity: 0;
733
+ transform: translate(-50%, -2px) scale(0.98);
734
+ }
735
+ }
736
+ </style>