plum-e2e 2.5.0 → 2.5.2

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/.env ADDED
@@ -0,0 +1,19 @@
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
+
19
+ IS_HEADLESS=true
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
 
@@ -32,7 +32,12 @@ function defaults() {
32
32
  return {
33
33
  headless: false,
34
34
  backendPort: '3001',
35
- frontendPort: '5173'
35
+ frontendPort: '5173',
36
+ // Public URLs the browser actually uses. Left blank until the user sets
37
+ // them (e.g. behind a reverse proxy) — resolved to a localhost default
38
+ // at the call site otherwise.
39
+ apiUrl: '',
40
+ uiUrl: ''
36
41
  };
37
42
  }
38
43
 
@@ -63,10 +68,10 @@ function loadServerConfig(dir) {
63
68
  }
64
69
 
65
70
  function saveServerConfig(dir, cfg) {
66
- const { headless, backendPort, frontendPort } = cfg;
71
+ const { headless, backendPort, frontendPort, apiUrl, uiUrl } = cfg;
67
72
  fs.writeFileSync(
68
73
  configPath(dir),
69
- JSON.stringify({ headless, backendPort, frontendPort }, null, 2) + '\n',
74
+ JSON.stringify({ headless, backendPort, frontendPort, apiUrl, uiUrl }, null, 2) + '\n',
70
75
  'utf8'
71
76
  );
72
77
  }
@@ -88,25 +93,26 @@ function writeEnvFile(dir, { headless }) {
88
93
  }
89
94
 
90
95
  /**
91
- * Builds docker-compose.override.yml. Containers keep their internal ports
92
- * (3001/5173); only the host side is remapped. The frontend is told where to
93
- * reach the backend via VITE_API_URL (read by Vite at dev runtime).
96
+ * Builds docker-compose.override.yml. Host port remapping is handled by
97
+ * BACKEND_PORT/FRONTEND_PORT env vars read by docker-compose.yml itself
98
+ * (${BACKEND_PORT:-3001} etc) NOT here, because Compose merges `ports:`
99
+ * lists across files by concatenation rather than replacing them. Defining
100
+ * ports in both the base file and this override would publish both values
101
+ * simultaneously, and fail to start if the base file's default port happens
102
+ * to already be taken. This override only adds volumes and tells the
103
+ * frontend where to reach the backend via VITE_API_URL.
94
104
  */
95
- function buildOverrideYaml({ testsAbs, reportsAbs, backendPort, frontendPort }) {
105
+ function buildOverrideYaml({ testsAbs, reportsAbs, backendPort, apiUrl }) {
96
106
  return (
97
107
  [
98
108
  'services:',
99
109
  ' backend:',
100
- ' ports:',
101
- ` - "${backendPort}:3001"`,
102
110
  ' volumes:',
103
111
  ` - "${reportsAbs}:/app/reports"`,
104
112
  ` - "${testsAbs}:/app/tests"`,
105
113
  ' frontend:',
106
- ' ports:',
107
- ` - "${frontendPort}:5173"`,
108
114
  ' environment:',
109
- ` VITE_API_URL: "http://localhost:${backendPort}"`
115
+ ` VITE_API_URL: "${apiUrl || `http://localhost:${backendPort}`}"`
110
116
  ].join('\n') + '\n'
111
117
  );
112
118
  }
@@ -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
@@ -188,13 +188,23 @@ async function configureServer({ force }) {
188
188
  const overrides = {
189
189
  headless: getFlag(args, '--headless'),
190
190
  backendPort: getFlag(args, '--backend-port'),
191
- frontendPort: getFlag(args, '--frontend-port')
191
+ frontendPort: getFlag(args, '--frontend-port'),
192
+ apiUrl: getFlag(args, '--api-url'),
193
+ uiUrl: getFlag(args, '--ui-url')
192
194
  };
193
195
  if (overrides.headless !== undefined) cfg.headless = overrides.headless === 'true';
194
196
  if (overrides.backendPort !== undefined) cfg.backendPort = overrides.backendPort;
195
197
  if (overrides.frontendPort !== undefined) cfg.frontendPort = overrides.frontendPort;
198
+ if (overrides.apiUrl !== undefined) cfg.apiUrl = overrides.apiUrl;
199
+ if (overrides.uiUrl !== undefined) cfg.uiUrl = overrides.uiUrl;
196
200
 
197
- const hasFlags = anyFlags(args, ['--headless', '--backend-port', '--frontend-port']);
201
+ const hasFlags = anyFlags(args, [
202
+ '--headless',
203
+ '--backend-port',
204
+ '--frontend-port',
205
+ '--api-url',
206
+ '--ui-url'
207
+ ]);
198
208
  const interactive = force || (interactiveAllowed() && !hasFlags);
199
209
 
200
210
  if (interactive) {
@@ -220,6 +230,27 @@ async function configureServer({ force }) {
220
230
  });
221
231
  if (clack.isCancel(frontendPort)) cancelAndExit();
222
232
  cfg.frontendPort = frontendPort || cfg.frontendPort;
233
+
234
+ const defaultApiUrl = `http://localhost:${cfg.backendPort}`;
235
+ const apiUrl = await clack.text({
236
+ message: 'Public URL for the API (only if reverse-proxying behind a domain)',
237
+ placeholder: cfg.apiUrl || defaultApiUrl,
238
+ defaultValue: cfg.apiUrl || defaultApiUrl
239
+ });
240
+ if (clack.isCancel(apiUrl)) cancelAndExit();
241
+ cfg.apiUrl = apiUrl || defaultApiUrl;
242
+
243
+ const defaultUiUrl = `http://localhost:${cfg.frontendPort}`;
244
+ const uiUrl = await clack.text({
245
+ message: 'Public URL for the UI (only if reverse-proxying behind a domain)',
246
+ placeholder: cfg.uiUrl || defaultUiUrl,
247
+ defaultValue: cfg.uiUrl || defaultUiUrl
248
+ });
249
+ if (clack.isCancel(uiUrl)) cancelAndExit();
250
+ cfg.uiUrl = uiUrl || defaultUiUrl;
251
+ } else {
252
+ if (!cfg.apiUrl) cfg.apiUrl = `http://localhost:${cfg.backendPort}`;
253
+ if (!cfg.uiUrl) cfg.uiUrl = `http://localhost:${cfg.frontendPort}`;
223
254
  }
224
255
 
225
256
  saveServerConfig(cwd, cfg);
@@ -240,27 +271,27 @@ function applyServerConfig(cfg) {
240
271
  testsAbs,
241
272
  reportsAbs,
242
273
  backendPort: cfg.backendPort,
243
- frontendPort: cfg.frontendPort
274
+ apiUrl: cfg.apiUrl
244
275
  }),
245
276
  'utf8'
246
277
  );
247
278
  clack.log.success('docker-compose.override.yml written');
248
279
  }
249
280
 
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' });
281
+ // Node's fetch resolves "localhost" to ::1 first on many Linux distros (Debian
282
+ // included). If Docker only published the port on IPv4, that first attempt hangs
283
+ // until it times out on every poll, eating the whole budget even though the port
284
+ // is reachable — and reachable fine from a browser, which races both families.
285
+ // 127.0.0.1 sidesteps the DNS/happy-eyeballs mismatch entirely.
286
+ const READY_POLL_INTERVAL_MS = 2000;
287
+ const READY_POLL_MAX_ATTEMPTS = 90; // ~3 minutes
257
288
 
258
- const apiBase = `http://localhost:${cfg.backendPort}`;
289
+ async function waitForServerReady(apiBase) {
259
290
  const s = clack.spinner();
260
291
  s.start('Waiting for server to be ready…');
261
292
  let ready = false;
262
- for (let i = 0; i < 40; i++) {
263
- await new Promise((r) => setTimeout(r, 1500));
293
+ for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) {
294
+ await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS));
264
295
  try {
265
296
  const res = await fetch(`${apiBase}/auth/needs-setup`);
266
297
  if (res.ok) {
@@ -268,58 +299,123 @@ async function serverStart() {
268
299
  break;
269
300
  }
270
301
  } catch {}
302
+ if (i > 0 && i % 15 === 0) {
303
+ s.message(
304
+ `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)`
305
+ );
306
+ }
271
307
  }
272
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
308
+ s.stop(
309
+ ready
310
+ ? pc.green('✓ Server is ready')
311
+ : pc.yellow('Server did not respond in time — it may still be starting')
312
+ );
313
+ return ready;
314
+ }
273
315
 
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 {}
316
+ async function runFirstUserSetup(apiBase, uiUrl) {
317
+ let needsSetup = false;
318
+ try {
319
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
320
+ const data = await res.json();
321
+ needsSetup = data.needsSetup;
322
+ } catch {}
281
323
 
282
- if (needsSetup) {
283
- clack.log.info('No users found — create your first account to get started.');
324
+ if (!needsSetup) return;
284
325
 
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
- }
326
+ if (!interactiveAllowed()) {
327
+ clack.log.info(
328
+ `No users found. Open ${pc.cyan(`${uiUrl}/setup`)} to create your first account.`
329
+ );
330
+ return;
331
+ }
332
+
333
+ clack.log.info('No users found — create your first account to get started.');
334
+
335
+ const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
336
+ if (clack.isCancel(name)) {
337
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
338
+ return;
339
+ }
340
+ const email = await clack.text({ message: 'Email address', placeholder: 'jane@example.com' });
341
+ if (clack.isCancel(email)) {
342
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
343
+ return;
344
+ }
345
+ const password = await clack.password({ message: 'Password (min 8 characters)' });
346
+ if (clack.isCancel(password)) {
347
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
348
+ return;
349
+ }
350
+
351
+ try {
352
+ const res = await fetch(`${apiBase}/auth/setup`, {
353
+ method: 'POST',
354
+ headers: { 'Content-Type': 'application/json' },
355
+ body: JSON.stringify({ name, email, password })
356
+ });
357
+ if (res.ok) {
358
+ clack.log.success(`Account created for ${email}. You can now log in.`);
359
+ } else {
360
+ const err = await res.json();
361
+ clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
318
362
  }
363
+ } catch (e) {
364
+ clack.log.error(`Failed to create account: ${e.message}`);
365
+ }
366
+ }
367
+
368
+ // docker compose failures (port conflicts, daemon not running, etc.) must not
369
+ // crash the process with a raw stack trace — that would abort serverStart()
370
+ // before it ever reaches the first-user prompt, with no indication why.
371
+ function runDockerComposeUp(cfg) {
372
+ try {
373
+ execSync('docker compose up --build -d', {
374
+ cwd: plumRoot,
375
+ stdio: 'inherit',
376
+ env: {
377
+ ...process.env,
378
+ BACKEND_PORT: String(cfg.backendPort),
379
+ FRONTEND_PORT: String(cfg.frontendPort)
380
+ }
381
+ });
382
+ return true;
383
+ } catch {
384
+ clack.log.error(
385
+ `Docker failed to start the stack — see the output above for the cause.\n` +
386
+ `A common cause is another process already using port ${cfg.backendPort} or ${cfg.frontendPort}; ` +
387
+ `try ${pc.cyan('plum server reconfig')} to pick different ports.`
388
+ );
389
+ return false;
390
+ }
391
+ }
392
+
393
+ async function serverStart() {
394
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
395
+ const cfg = await configureServer({ force: false });
396
+ applyServerConfig(cfg);
397
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
398
+
399
+ if (!runDockerComposeUp(cfg)) {
400
+ clack.outro(pc.red('Plum did not start.'));
401
+ process.exitCode = 1;
402
+ return;
403
+ }
404
+
405
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
406
+ const ready = await waitForServerReady(apiBase);
407
+
408
+ if (ready) {
409
+ await runFirstUserSetup(apiBase, cfg.uiUrl);
410
+ } else {
411
+ clack.log.warn(
412
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.\n` +
413
+ `Once it responds, open ${pc.cyan(`${cfg.uiUrl}/setup`)} to create your first account (if this is a fresh install).`
414
+ );
319
415
  }
320
416
 
321
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
322
- clack.log.info(`API: ${pc.cyan(`http://localhost:${cfg.backendPort}`)}`);
417
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
418
+ clack.log.info(`API: ${pc.cyan(cfg.apiUrl)}`);
323
419
  clack.outro(pc.green('Plum is running. Use "plum server stop" to shut down.'));
324
420
  }
325
421
 
@@ -328,27 +424,23 @@ async function serverRestart() {
328
424
  const { loadServerConfig } = serverConfigLib();
329
425
  const cfg = loadServerConfig(process.cwd());
330
426
  applyServerConfig(cfg);
331
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
427
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
332
428
 
333
- execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
429
+ if (!runDockerComposeUp(cfg)) {
430
+ clack.outro(pc.red('Server did not restart.'));
431
+ process.exitCode = 1;
432
+ return;
433
+ }
334
434
 
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 {}
435
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
436
+ const ready = await waitForServerReady(apiBase);
437
+ if (!ready) {
438
+ clack.log.warn(
439
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.`
440
+ );
348
441
  }
349
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
350
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
351
- clack.log.info(`API: ${pc.cyan(`http://localhost:${cfg.backendPort}`)}`);
442
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
443
+ clack.log.info(`API: ${pc.cyan(cfg.apiUrl)}`);
352
444
  clack.outro(pc.green('Server restarted.'));
353
445
  }
354
446
 
@@ -391,7 +483,7 @@ async function serverReconfig() {
391
483
  const cfg = await configureServer({ force: true });
392
484
  applyServerConfig(cfg);
393
485
  clack.log.success("Saved. Run 'plum server start' to apply.");
394
- clack.outro(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
486
+ clack.outro(`UI: ${pc.cyan(cfg.uiUrl)}`);
395
487
  }
396
488
 
397
489
  /* -----------------------------------------------------
@@ -1082,6 +1174,12 @@ switch (command) {
1082
1174
  console.log(' --headless <bool> Run browsers headless (true/false)');
1083
1175
  console.log(' --backend-port <n> Host port for the backend/API (default: 3001)');
1084
1176
  console.log(' --frontend-port <n> Host port for the UI (default: 5173)');
1177
+ console.log(
1178
+ ' --api-url <url> Public URL for the API (only if reverse-proxying; default: http://localhost:<backend-port>)'
1179
+ );
1180
+ console.log(
1181
+ ' --ui-url <url> Public URL for the UI (only if reverse-proxying; default: http://localhost:<frontend-port>)'
1182
+ );
1085
1183
  console.log(' server restart Rebuild Docker images and restart the server (no prompts)');
1086
1184
  console.log(' server stop Stop the server (data preserved)');
1087
1185
  console.log(' server reconfig Re-enter server settings without starting');
@@ -35,7 +35,7 @@ services:
35
35
  backend:
36
36
  build: ./backend
37
37
  ports:
38
- - '3001:3001'
38
+ - '${BACKEND_PORT:-3001}:3001'
39
39
  environment:
40
40
  DATABASE_URL: 'postgresql://plum:plum@postgres:5432/plum'
41
41
  extra_hosts:
@@ -52,7 +52,7 @@ services:
52
52
  frontend:
53
53
  build: ./frontend
54
54
  ports:
55
- - '5173:5173'
55
+ - '${FRONTEND_PORT:-5173}:5173'
56
56
  depends_on:
57
57
  - backend
58
58
  networks:
@@ -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.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"