plum-e2e 2.5.0 → 2.5.1

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/CLAUDE.md CHANGED
@@ -145,7 +145,7 @@ The GPL license header at the top of every file is a legal requirement — do no
145
145
  - All schema changes require a Prisma migration file in `backend/prisma/migrations/`.
146
146
  - Run `npx prisma migrate deploy` (or rebuild Docker) to apply.
147
147
  - Report content is stored as JSONB in the `Report.content` column — never reconstruct report metadata from filenames.
148
- - The `getAllReports()` service function deliberately excludes `content` for list performance — only `getReportDetail(id)` fetches it.
148
+ - The `getReports()` service function is paginated (`page`/`limit`) and deliberately excludes `content` for list performance — only `getReportDetail(id)` fetches it.
149
149
 
150
150
  ---
151
151
 
@@ -0,0 +1,44 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Report" ALTER COLUMN "content" DROP DEFAULT;
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "TestRun" ALTER COLUMN "status" SET DEFAULT 'backlog';
6
+
7
+ -- CreateIndex
8
+ CREATE INDEX "Report_createdAt_idx" ON "Report"("createdAt");
9
+
10
+ -- CreateIndex
11
+ CREATE INDEX "Report_status_idx" ON "Report"("status");
12
+
13
+ -- CreateIndex
14
+ CREATE INDEX "Report_testRunId_idx" ON "Report"("testRunId");
15
+
16
+ -- CreateIndex
17
+ CREATE INDEX "Report_runnerId_idx" ON "Report"("runnerId");
18
+
19
+ -- CreateIndex
20
+ CREATE INDEX "Report_cronJobId_idx" ON "Report"("cronJobId");
21
+
22
+ -- CreateIndex
23
+ CREATE INDEX "TestCase_suiteId_idx" ON "TestCase"("suiteId");
24
+
25
+ -- CreateIndex
26
+ CREATE INDEX "TestCaseHistory_caseId_idx" ON "TestCaseHistory"("caseId");
27
+
28
+ -- CreateIndex
29
+ CREATE INDEX "TestCaseHistory_runId_idx" ON "TestCaseHistory"("runId");
30
+
31
+ -- CreateIndex
32
+ CREATE INDEX "TestCaseHistory_reportId_idx" ON "TestCaseHistory"("reportId");
33
+
34
+ -- CreateIndex
35
+ CREATE INDEX "TestRun_createdAt_idx" ON "TestRun"("createdAt");
36
+
37
+ -- CreateIndex
38
+ CREATE INDEX "TestRunEntry_runId_idx" ON "TestRunEntry"("runId");
39
+
40
+ -- CreateIndex
41
+ CREATE INDEX "TestRunEntry_caseId_idx" ON "TestRunEntry"("caseId");
42
+
43
+ -- CreateIndex
44
+ CREATE INDEX "TestSuite_createdAt_idx" ON "TestSuite"("createdAt");
@@ -70,6 +70,12 @@ model Report {
70
70
  logs String?
71
71
  createdAt DateTime @default(now())
72
72
  testHistory TestCaseHistory[]
73
+
74
+ @@index([createdAt])
75
+ @@index([status])
76
+ @@index([testRunId])
77
+ @@index([runnerId])
78
+ @@index([cronJobId])
73
79
  }
74
80
 
75
81
  model Project {
@@ -124,6 +130,8 @@ model TestSuite {
124
130
  createdAt DateTime @default(now())
125
131
  updatedAt DateTime @updatedAt
126
132
  cases TestCase[]
133
+
134
+ @@index([createdAt])
127
135
  }
128
136
 
129
137
  model TestCase {
@@ -142,6 +150,8 @@ model TestCase {
142
150
  steps TestStep[]
143
151
  runEntries TestRunEntry[]
144
152
  history TestCaseHistory[]
153
+
154
+ @@index([suiteId])
145
155
  }
146
156
 
147
157
  model TestStep {
@@ -166,6 +176,8 @@ model TestRun {
166
176
  entries TestRunEntry[]
167
177
  history TestCaseHistory[]
168
178
  reports Report[]
179
+
180
+ @@index([createdAt])
169
181
  }
170
182
 
171
183
  model TestRunEntry {
@@ -182,6 +194,9 @@ model TestRunEntry {
182
194
  executedAt DateTime?
183
195
  assignedToId String?
184
196
  assignedTo User? @relation("entryAssignee", fields: [assignedToId], references: [id])
197
+
198
+ @@index([runId])
199
+ @@index([caseId])
185
200
  }
186
201
 
187
202
  model TestCaseHistory {
@@ -198,4 +213,8 @@ model TestCaseHistory {
198
213
  executedById String?
199
214
  executedBy User? @relation(fields: [executedById], references: [id])
200
215
  executedAt DateTime @default(now())
216
+
217
+ @@index([caseId])
218
+ @@index([runId])
219
+ @@index([reportId])
201
220
  }
@@ -21,8 +21,10 @@ const reportService = require('../services/reportService');
21
21
 
22
22
  router.get('/', async (req, res) => {
23
23
  try {
24
- const reports = await reportService.getAllReports();
25
- res.json({ reports });
24
+ const page = Math.max(1, parseInt(req.query.page) || 1);
25
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 15));
26
+ const result = await reportService.getReports({ page, limit });
27
+ res.json(result);
26
28
  } catch {
27
29
  res.status(500).json({ error: 'Failed to fetch reports' });
28
30
  }
@@ -235,21 +235,39 @@ function processCucumberJson(raw) {
235
235
  // Read operations
236
236
  // ---------------------------------------------------------------------------
237
237
 
238
- const getAllReports = () =>
239
- prisma.report.findMany({
240
- orderBy: { createdAt: 'desc' },
241
- select: {
242
- id: true,
243
- status: true,
244
- tags: true,
245
- triggerType: true,
246
- runners: true,
247
- browser: true,
248
- runnerName: true,
249
- createdAt: true,
250
- testRun: { select: { id: true, title: true } }
251
- }
252
- });
238
+ const reportListSelect = {
239
+ id: true,
240
+ status: true,
241
+ tags: true,
242
+ triggerType: true,
243
+ runners: true,
244
+ browser: true,
245
+ runnerName: true,
246
+ createdAt: true,
247
+ testRun: { select: { id: true, title: true } }
248
+ };
249
+
250
+ const TREND_SIZE = 12;
251
+
252
+ const getReports = async ({ page = 1, limit = 15 } = {}) => {
253
+ const skip = (page - 1) * limit;
254
+ const [reports, total, passCount, trend] = await Promise.all([
255
+ prisma.report.findMany({
256
+ orderBy: { createdAt: 'desc' },
257
+ skip,
258
+ take: limit,
259
+ select: reportListSelect
260
+ }),
261
+ prisma.report.count(),
262
+ prisma.report.count({ where: { status: 'PASS' } }),
263
+ prisma.report.findMany({
264
+ orderBy: { createdAt: 'desc' },
265
+ take: TREND_SIZE,
266
+ select: { id: true, status: true, tags: true, createdAt: true }
267
+ })
268
+ ]);
269
+ return { reports, total, passCount, failCount: total - passCount, trend };
270
+ };
253
271
 
254
272
  const getLatestReportId = async () => {
255
273
  const report = await prisma.report.findFirst({
@@ -444,7 +462,7 @@ async function syncAutomatedFromFeatures() {
444
462
  }
445
463
 
446
464
  module.exports = {
447
- getAllReports,
465
+ getReports,
448
466
  getLatestReportId,
449
467
  getReportDetail,
450
468
  saveReport,
package/bin/plum.js CHANGED
@@ -247,20 +247,20 @@ function applyServerConfig(cfg) {
247
247
  clack.log.success('docker-compose.override.yml written');
248
248
  }
249
249
 
250
- async function serverStart() {
251
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum Server ')));
252
- const cfg = await configureServer({ force: false });
253
- applyServerConfig(cfg);
254
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
255
-
256
- execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
257
-
258
- const apiBase = `http://localhost:${cfg.backendPort}`;
250
+ // Node's fetch resolves "localhost" to ::1 first on many Linux distros (Debian
251
+ // included). If Docker only published the port on IPv4, that first attempt hangs
252
+ // until it times out on every poll, eating the whole budget even though the port
253
+ // is reachable — and reachable fine from a browser, which races both families.
254
+ // 127.0.0.1 sidesteps the DNS/happy-eyeballs mismatch entirely.
255
+ const READY_POLL_INTERVAL_MS = 2000;
256
+ const READY_POLL_MAX_ATTEMPTS = 90; // ~3 minutes
257
+
258
+ async function waitForServerReady(apiBase) {
259
259
  const s = clack.spinner();
260
260
  s.start('Waiting for server to be ready…');
261
261
  let ready = false;
262
- for (let i = 0; i < 40; i++) {
263
- await new Promise((r) => setTimeout(r, 1500));
262
+ for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) {
263
+ await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS));
264
264
  try {
265
265
  const res = await fetch(`${apiBase}/auth/needs-setup`);
266
266
  if (res.ok) {
@@ -268,54 +268,90 @@ async function serverStart() {
268
268
  break;
269
269
  }
270
270
  } catch {}
271
+ if (i > 0 && i % 15 === 0) {
272
+ s.message(
273
+ `Still waiting for server to be ready… (${Math.round((i * READY_POLL_INTERVAL_MS) / 1000)}s — check "docker compose logs -f backend" if this feels stuck)`
274
+ );
275
+ }
271
276
  }
272
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
277
+ s.stop(
278
+ ready
279
+ ? pc.green('✓ Server is ready')
280
+ : pc.yellow('Server did not respond in time — it may still be starting')
281
+ );
282
+ return ready;
283
+ }
273
284
 
274
- if (ready) {
275
- let needsSetup = false;
276
- try {
277
- const res = await fetch(`${apiBase}/auth/needs-setup`);
278
- const data = await res.json();
279
- needsSetup = data.needsSetup;
280
- } catch {}
285
+ async function runFirstUserSetup(apiBase, frontendPort) {
286
+ let needsSetup = false;
287
+ try {
288
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
289
+ const data = await res.json();
290
+ needsSetup = data.needsSetup;
291
+ } catch {}
281
292
 
282
- if (needsSetup) {
283
- clack.log.info('No users found — create your first account to get started.');
293
+ if (!needsSetup) return;
284
294
 
285
- const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
286
- if (clack.isCancel(name)) {
287
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
288
- } else {
289
- const email = await clack.text({
290
- message: 'Email address',
291
- placeholder: 'jane@example.com'
292
- });
293
- if (clack.isCancel(email)) {
294
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
295
- } else {
296
- const password = await clack.password({ message: 'Password (min 8 characters)' });
297
- if (clack.isCancel(password)) {
298
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
299
- } else {
300
- try {
301
- const res = await fetch(`${apiBase}/auth/setup`, {
302
- method: 'POST',
303
- headers: { 'Content-Type': 'application/json' },
304
- body: JSON.stringify({ name, email, password })
305
- });
306
- if (res.ok) {
307
- clack.log.success(`Account created for ${email}. You can now log in.`);
308
- } else {
309
- const err = await res.json();
310
- clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
311
- }
312
- } catch (e) {
313
- clack.log.error(`Failed to create account: ${e.message}`);
314
- }
315
- }
316
- }
317
- }
295
+ if (!interactiveAllowed()) {
296
+ clack.log.info(
297
+ `No users found. Open ${pc.cyan(`http://localhost:${frontendPort}/setup`)} to create your first account.`
298
+ );
299
+ return;
300
+ }
301
+
302
+ clack.log.info('No users found — create your first account to get started.');
303
+
304
+ const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
305
+ if (clack.isCancel(name)) {
306
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
307
+ return;
308
+ }
309
+ const email = await clack.text({ message: 'Email address', placeholder: 'jane@example.com' });
310
+ if (clack.isCancel(email)) {
311
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
312
+ return;
313
+ }
314
+ const password = await clack.password({ message: 'Password (min 8 characters)' });
315
+ if (clack.isCancel(password)) {
316
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
317
+ return;
318
+ }
319
+
320
+ try {
321
+ const res = await fetch(`${apiBase}/auth/setup`, {
322
+ method: 'POST',
323
+ headers: { 'Content-Type': 'application/json' },
324
+ body: JSON.stringify({ name, email, password })
325
+ });
326
+ if (res.ok) {
327
+ clack.log.success(`Account created for ${email}. You can now log in.`);
328
+ } else {
329
+ const err = await res.json();
330
+ clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
318
331
  }
332
+ } catch (e) {
333
+ clack.log.error(`Failed to create account: ${e.message}`);
334
+ }
335
+ }
336
+
337
+ async function serverStart() {
338
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
339
+ const cfg = await configureServer({ force: false });
340
+ applyServerConfig(cfg);
341
+ clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
342
+
343
+ execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
344
+
345
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
346
+ const ready = await waitForServerReady(apiBase);
347
+
348
+ if (ready) {
349
+ await runFirstUserSetup(apiBase, cfg.frontendPort);
350
+ } else {
351
+ clack.log.warn(
352
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.\n` +
353
+ `Once it responds, open ${pc.cyan(`http://localhost:${cfg.frontendPort}/setup`)} to create your first account (if this is a fresh install).`
354
+ );
319
355
  }
320
356
 
321
357
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
@@ -332,21 +368,13 @@ async function serverRestart() {
332
368
 
333
369
  execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
334
370
 
335
- const apiBase = `http://localhost:${cfg.backendPort}`;
336
- const s = clack.spinner();
337
- s.start('Waiting for server to be ready…');
338
- let ready = false;
339
- for (let i = 0; i < 40; i++) {
340
- await new Promise((r) => setTimeout(r, 1500));
341
- try {
342
- const res = await fetch(`${apiBase}/auth/needs-setup`);
343
- if (res.ok) {
344
- ready = true;
345
- break;
346
- }
347
- } catch {}
371
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
372
+ const ready = await waitForServerReady(apiBase);
373
+ if (!ready) {
374
+ clack.log.warn(
375
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.`
376
+ );
348
377
  }
349
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
350
378
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
351
379
  clack.log.info(`API: ${pc.cyan(`http://localhost:${cfg.backendPort}`)}`);
352
380
  clack.outro(pc.green('Server restarted.'));
@@ -15,12 +15,23 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import { API_BASE } from '$lib/constants';
18
+ import { API_BASE, REPORTS_PER_PAGE } from '$lib/constants';
19
19
 
20
- export async function fetchReports() {
21
- const res = await fetch(`${API_BASE}/reports`);
22
- const { reports } = await res.json();
23
- return reports.map((r) => ({ ...r, date: new Date(r.createdAt).toLocaleString() }));
20
+ function withDate(r) {
21
+ return { ...r, date: new Date(r.createdAt).toLocaleString() };
22
+ }
23
+
24
+ export async function fetchReports({ page = 1, limit = REPORTS_PER_PAGE } = {}) {
25
+ const params = new URLSearchParams({ page, limit });
26
+ const res = await fetch(`${API_BASE}/reports?${params}`);
27
+ const { reports, total, passCount, failCount, trend } = await res.json();
28
+ return {
29
+ reports: reports.map(withDate),
30
+ total,
31
+ passCount,
32
+ failCount,
33
+ trend: trend.map(withDate)
34
+ };
24
35
  }
25
36
 
26
37
  export async function fetchLatestReportId() {
@@ -16,14 +16,27 @@
16
16
  -->
17
17
 
18
18
  <script>
19
+ import { onMount } from 'svelte';
20
+ import { goto } from '$app/navigation';
19
21
  import { auth } from '$lib/stores/auth';
20
- import { login } from '$lib/api/auth';
22
+ import { login, checkNeedsSetup } from '$lib/api/auth';
21
23
  import { theme } from '$lib/stores/theme';
22
24
 
23
25
  let email = '';
24
26
  let password = '';
25
27
  let error = '';
26
28
  let loading = false;
29
+ let checking = true;
30
+
31
+ onMount(async () => {
32
+ try {
33
+ if (await checkNeedsSetup()) {
34
+ goto('/setup');
35
+ return;
36
+ }
37
+ } catch {}
38
+ checking = false;
39
+ });
27
40
 
28
41
  async function handleSubmit() {
29
42
  error = '';
@@ -47,48 +60,50 @@
47
60
  <svelte:head><title>Sign in — Plum</title></svelte:head>
48
61
 
49
62
  <div class="page" data-theme={$theme}>
50
- <div class="card">
51
- <div class="brand">
52
- <span class="brand-serif">Pl</span><span class="brand-sans">um</span>
53
- </div>
54
- <h1 class="title">Sign in</h1>
55
- <p class="subtitle">Access your test workspace</p>
56
-
57
- <div class="fields">
58
- <div class="field">
59
- <label class="label" for="email">Email</label>
60
- <input
61
- id="email"
62
- type="email"
63
- class="input"
64
- bind:value={email}
65
- placeholder="jane@example.com"
66
- autocomplete="email"
67
- on:keydown={onKeydown}
68
- />
63
+ {#if !checking}
64
+ <div class="card">
65
+ <div class="brand">
66
+ <span class="brand-serif">Pl</span><span class="brand-sans">um</span>
69
67
  </div>
70
- <div class="field">
71
- <label class="label" for="password">Password</label>
72
- <input
73
- id="password"
74
- type="password"
75
- class="input"
76
- bind:value={password}
77
- placeholder="••••••••"
78
- autocomplete="current-password"
79
- on:keydown={onKeydown}
80
- />
68
+ <h1 class="title">Sign in</h1>
69
+ <p class="subtitle">Access your test workspace</p>
70
+
71
+ <div class="fields">
72
+ <div class="field">
73
+ <label class="label" for="email">Email</label>
74
+ <input
75
+ id="email"
76
+ type="email"
77
+ class="input"
78
+ bind:value={email}
79
+ placeholder="jane@example.com"
80
+ autocomplete="email"
81
+ on:keydown={onKeydown}
82
+ />
83
+ </div>
84
+ <div class="field">
85
+ <label class="label" for="password">Password</label>
86
+ <input
87
+ id="password"
88
+ type="password"
89
+ class="input"
90
+ bind:value={password}
91
+ placeholder="••••••••"
92
+ autocomplete="current-password"
93
+ on:keydown={onKeydown}
94
+ />
95
+ </div>
81
96
  </div>
82
- </div>
83
97
 
84
- {#if error}
85
- <p class="error">{error}</p>
86
- {/if}
98
+ {#if error}
99
+ <p class="error">{error}</p>
100
+ {/if}
87
101
 
88
- <button class="submit-btn" on:click={handleSubmit} disabled={loading || !email || !password}>
89
- {loading ? 'Signing in…' : 'Sign in'}
90
- </button>
91
- </div>
102
+ <button class="submit-btn" on:click={handleSubmit} disabled={loading || !email || !password}>
103
+ {loading ? 'Signing in…' : 'Sign in'}
104
+ </button>
105
+ </div>
106
+ {/if}
92
107
  </div>
93
108
 
94
109
  <style>
@@ -27,6 +27,10 @@
27
27
  import EmptyState from '$lib/components/ui/EmptyState.svelte';
28
28
 
29
29
  let reports = [];
30
+ let total = 0;
31
+ let passCount = 0;
32
+ let failCount = 0;
33
+ let trend = [];
30
34
  let currentPage = 1;
31
35
  let animateBar = false;
32
36
 
@@ -34,21 +38,24 @@
34
38
  let deleteModal = { open: false, targets: [] };
35
39
  let deleting = false;
36
40
 
37
- $: totalPages = Math.ceil(reports.length / REPORTS_PER_PAGE);
38
- $: paginated = reports.slice(
39
- (currentPage - 1) * REPORTS_PER_PAGE,
40
- currentPage * REPORTS_PER_PAGE
41
- );
42
- $: passCount = reports.filter((r) => r.status === 'PASS').length;
43
- $: failCount = reports.length - passCount;
44
- $: passRate = reports.length ? Math.round((passCount / reports.length) * 100) : 0;
45
- $: trend = reports.slice(0, 12).reverse();
46
- $: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.id));
41
+ $: totalPages = Math.ceil(total / REPORTS_PER_PAGE);
42
+ $: passRate = total ? Math.round((passCount / total) * 100) : 0;
43
+ $: trendDots = [...trend].reverse();
44
+ $: allOnPageSelected = reports.length > 0 && reports.every((r) => selected.has(r.id));
47
45
  $: someSelected = selected.size > 0;
48
46
 
49
47
  async function loadReports() {
50
48
  try {
51
- reports = await fetchReports();
49
+ let data = await fetchReports({ page: currentPage, limit: REPORTS_PER_PAGE });
50
+ if (data.reports.length === 0 && currentPage > 1) {
51
+ currentPage = Math.max(1, Math.ceil(data.total / REPORTS_PER_PAGE));
52
+ data = await fetchReports({ page: currentPage, limit: REPORTS_PER_PAGE });
53
+ }
54
+ reports = data.reports;
55
+ total = data.total;
56
+ passCount = data.passCount;
57
+ failCount = data.failCount;
58
+ trend = data.trend;
52
59
  selected = new Set();
53
60
  await tick();
54
61
  animateBar = true;
@@ -57,6 +64,11 @@
57
64
  }
58
65
  }
59
66
 
67
+ function goToPage(page) {
68
+ currentPage = page;
69
+ loadReports();
70
+ }
71
+
60
72
  onMount(loadReports);
61
73
  $: if ($reportsVersion) loadReports();
62
74
 
@@ -73,11 +85,11 @@
73
85
  e.stopPropagation();
74
86
  if (allOnPageSelected) {
75
87
  const next = new Set(selected);
76
- paginated.forEach((r) => next.delete(r.id));
88
+ reports.forEach((r) => next.delete(r.id));
77
89
  selected = next;
78
90
  } else {
79
91
  const next = new Set(selected);
80
- paginated.forEach((r) => next.add(r.id));
92
+ reports.forEach((r) => next.add(r.id));
81
93
  selected = next;
82
94
  }
83
95
  }
@@ -133,11 +145,11 @@
133
145
  <div>
134
146
  <h1>Reports</h1>
135
147
  <p class="subtitle">
136
- {reports.length} run{reports.length !== 1 ? 's' : ''} recorded
148
+ {total} run{total !== 1 ? 's' : ''} recorded
137
149
  </p>
138
150
  </div>
139
151
 
140
- {#if reports.length > 0}
152
+ {#if total > 0}
141
153
  <div class="rate-display">
142
154
  <span
143
155
  class="rate-number"
@@ -152,7 +164,7 @@
152
164
  {/if}
153
165
  </div>
154
166
 
155
- {#if reports.length > 0}
167
+ {#if total > 0}
156
168
  <div class="stats-bar">
157
169
  <div class="pass-bar-track">
158
170
  <div class="pass-bar-fill" style="width: {animateBar ? passRate + '%' : '0'}"></div>
@@ -166,7 +178,7 @@
166
178
  <div class="trend-row">
167
179
  <span class="trend-label">Recent</span>
168
180
  <div class="trend-dots">
169
- {#each trend as r, i}
181
+ {#each trendDots as r, i}
170
182
  <span
171
183
  class="trend-dot"
172
184
  class:pass={r.status === 'PASS'}
@@ -181,7 +193,7 @@
181
193
  {/if}
182
194
  </div>
183
195
 
184
- {#if reports.length === 0}
196
+ {#if total === 0}
185
197
  <EmptyState message="No reports yet. Run a test to generate one." />
186
198
  {:else}
187
199
  <div class="list-header">
@@ -202,7 +214,7 @@
202
214
  </div>
203
215
 
204
216
  <div class="report-list">
205
- {#each paginated as report, i}
217
+ {#each reports as report, i}
206
218
  <div class="report-row" class:is-selected={selected.has(report.id)} style={stagger(i)}>
207
219
  <label class="row-check-wrap" title="Select">
208
220
  <input
@@ -287,11 +299,7 @@
287
299
 
288
300
  {#if totalPages > 1}
289
301
  <div class="pagination-wrap">
290
- <Pagination
291
- current={currentPage}
292
- total={totalPages}
293
- on:change={(e) => (currentPage = e.detail)}
294
- />
302
+ <Pagination current={currentPage} total={totalPages} on:change={(e) => goToPage(e.detail)} />
295
303
  </div>
296
304
  {/if}
297
305
  {/if}
@@ -17,6 +17,7 @@
17
17
 
18
18
  <script>
19
19
  import { onMount } from 'svelte';
20
+ import { goto } from '$app/navigation';
20
21
  import { setup, checkNeedsSetup } from '$lib/api/auth';
21
22
  import { auth } from '$lib/stores/auth';
22
23
  import { login as apiLogin } from '$lib/api/auth';
@@ -174,13 +174,18 @@
174
174
  (tc) =>
175
175
  !runCaseIds.has(tc.id) &&
176
176
  (!search ||
177
+ suite.displayId.toLowerCase().includes(search.toLowerCase()) ||
178
+ suite.name.toLowerCase().includes(search.toLowerCase()) ||
177
179
  tc.title.toLowerCase().includes(search.toLowerCase()) ||
178
180
  tc.displayId.toLowerCase().includes(search.toLowerCase()))
179
181
  )
180
182
  }))
181
183
  .filter(
182
184
  (suite) =>
183
- !search || suite.cases.length > 0 || suite.name.toLowerCase().includes(search.toLowerCase())
185
+ !search ||
186
+ suite.cases.length > 0 ||
187
+ suite.displayId.toLowerCase().includes(search.toLowerCase()) ||
188
+ suite.name.toLowerCase().includes(search.toLowerCase())
184
189
  );
185
190
 
186
191
  function addCase(tc) {
@@ -19,5 +19,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
19
19
  import { defineConfig } from 'vite';
20
20
 
21
21
  export default defineConfig({
22
- plugins: [sveltekit()]
22
+ plugins: [sveltekit()],
23
+ server: {
24
+ // Plum is reverse-proxied behind whatever domain each self-hoster picks
25
+ // (see "Setting Up the Server" docs), so the Host header can't be known
26
+ // ahead of time. Disable Vite's dev-server host allowlist check entirely.
27
+ allowedHosts: true
28
+ }
23
29
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"