plum-e2e 2.5.1 → 2.5.3

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
@@ -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
  }
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,7 +271,7 @@ 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
  );
@@ -282,7 +313,7 @@ async function waitForServerReady(apiBase) {
282
313
  return ready;
283
314
  }
284
315
 
285
- async function runFirstUserSetup(apiBase, frontendPort) {
316
+ async function runFirstUserSetup(apiBase, uiUrl) {
286
317
  let needsSetup = false;
287
318
  try {
288
319
  const res = await fetch(`${apiBase}/auth/needs-setup`);
@@ -294,7 +325,7 @@ async function runFirstUserSetup(apiBase, frontendPort) {
294
325
 
295
326
  if (!interactiveAllowed()) {
296
327
  clack.log.info(
297
- `No users found. Open ${pc.cyan(`http://localhost:${frontendPort}/setup`)} to create your first account.`
328
+ `No users found. Open ${pc.cyan(`${uiUrl}/setup`)} to create your first account.`
298
329
  );
299
330
  return;
300
331
  }
@@ -334,28 +365,57 @@ async function runFirstUserSetup(apiBase, frontendPort) {
334
365
  }
335
366
  }
336
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
+
337
393
  async function serverStart() {
338
394
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
339
395
  const cfg = await configureServer({ force: false });
340
396
  applyServerConfig(cfg);
341
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
397
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
342
398
 
343
- execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
399
+ if (!runDockerComposeUp(cfg)) {
400
+ clack.outro(pc.red('Plum did not start.'));
401
+ process.exitCode = 1;
402
+ return;
403
+ }
344
404
 
345
405
  const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
346
406
  const ready = await waitForServerReady(apiBase);
347
407
 
348
408
  if (ready) {
349
- await runFirstUserSetup(apiBase, cfg.frontendPort);
409
+ await runFirstUserSetup(apiBase, cfg.uiUrl);
350
410
  } else {
351
411
  clack.log.warn(
352
412
  `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).`
413
+ `Once it responds, open ${pc.cyan(`${cfg.uiUrl}/setup`)} to create your first account (if this is a fresh install).`
354
414
  );
355
415
  }
356
416
 
357
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
358
- 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)}`);
359
419
  clack.outro(pc.green('Plum is running. Use "plum server stop" to shut down.'));
360
420
  }
361
421
 
@@ -364,9 +424,13 @@ async function serverRestart() {
364
424
  const { loadServerConfig } = serverConfigLib();
365
425
  const cfg = loadServerConfig(process.cwd());
366
426
  applyServerConfig(cfg);
367
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
427
+ clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
368
428
 
369
- 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
+ }
370
434
 
371
435
  const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
372
436
  const ready = await waitForServerReady(apiBase);
@@ -375,8 +439,8 @@ async function serverRestart() {
375
439
  `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.`
376
440
  );
377
441
  }
378
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
379
- 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)}`);
380
444
  clack.outro(pc.green('Server restarted.'));
381
445
  }
382
446
 
@@ -419,7 +483,7 @@ async function serverReconfig() {
419
483
  const cfg = await configureServer({ force: true });
420
484
  applyServerConfig(cfg);
421
485
  clack.log.success("Saved. Run 'plum server start' to apply.");
422
- clack.outro(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
486
+ clack.outro(`UI: ${pc.cyan(cfg.uiUrl)}`);
423
487
  }
424
488
 
425
489
  /* -----------------------------------------------------
@@ -1110,6 +1174,12 @@ switch (command) {
1110
1174
  console.log(' --headless <bool> Run browsers headless (true/false)');
1111
1175
  console.log(' --backend-port <n> Host port for the backend/API (default: 3001)');
1112
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
+ );
1113
1183
  console.log(' server restart Rebuild Docker images and restart the server (no prompts)');
1114
1184
  console.log(' server stop Stop the server (data preserved)');
1115
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:
@@ -17,15 +17,31 @@
17
17
 
18
18
  import { API_BASE } from '$lib/constants';
19
19
 
20
+ // A misconfigured or not-yet-routed API URL can accept a connection and never
21
+ // respond, rather than refusing outright — a plain fetch() would then hang
22
+ // forever with no error to catch. AUTH_TIMEOUT_MS bounds that so the UI always
23
+ // resolves one way or another instead of leaving the page blank indefinitely.
24
+ const AUTH_TIMEOUT_MS = 8000;
25
+
26
+ async function fetchWithTimeout(url, options = {}) {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
29
+ try {
30
+ return await fetch(url, { ...options, signal: controller.signal });
31
+ } finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+
20
36
  export async function checkNeedsSetup() {
21
- const res = await fetch(`${API_BASE}/auth/needs-setup`);
37
+ const res = await fetchWithTimeout(`${API_BASE}/auth/needs-setup`);
22
38
  if (!res.ok) return false;
23
39
  const data = await res.json();
24
40
  return data.needsSetup;
25
41
  }
26
42
 
27
43
  export async function setup({ name, email, password }) {
28
- const res = await fetch(`${API_BASE}/auth/setup`, {
44
+ const res = await fetchWithTimeout(`${API_BASE}/auth/setup`, {
29
45
  method: 'POST',
30
46
  headers: { 'Content-Type': 'application/json' },
31
47
  body: JSON.stringify({ name, email, password })
@@ -36,7 +52,7 @@ export async function setup({ name, email, password }) {
36
52
  }
37
53
 
38
54
  export async function login({ email, password }) {
39
- const res = await fetch(`${API_BASE}/auth/login`, {
55
+ const res = await fetchWithTimeout(`${API_BASE}/auth/login`, {
40
56
  method: 'POST',
41
57
  headers: { 'Content-Type': 'application/json' },
42
58
  body: JSON.stringify({ email, password })
@@ -47,7 +63,7 @@ export async function login({ email, password }) {
47
63
  }
48
64
 
49
65
  export async function updateProfile({ token, name, email }) {
50
- const res = await fetch(`${API_BASE}/auth/update-profile`, {
66
+ const res = await fetchWithTimeout(`${API_BASE}/auth/update-profile`, {
51
67
  method: 'PUT',
52
68
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
53
69
  body: JSON.stringify({ name, email })
@@ -58,7 +74,7 @@ export async function updateProfile({ token, name, email }) {
58
74
  }
59
75
 
60
76
  export async function changePassword({ token, currentPassword, newPassword }) {
61
- const res = await fetch(`${API_BASE}/auth/change-password`, {
77
+ const res = await fetchWithTimeout(`${API_BASE}/auth/change-password`, {
62
78
  method: 'POST',
63
79
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
64
80
  body: JSON.stringify({ currentPassword, newPassword })
@@ -62,4 +62,18 @@
62
62
  </PageShell>
63
63
  <RunnerPanel />
64
64
  {/if}
65
+ {:else}
66
+ <div class="boot-loading">Loading…</div>
65
67
  {/if}
68
+
69
+ <style>
70
+ .boot-loading {
71
+ min-height: 100vh;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ background: var(--bg);
76
+ color: var(--text-muted);
77
+ font-size: 0.875rem;
78
+ }
79
+ </style>
@@ -60,7 +60,9 @@
60
60
  <svelte:head><title>Sign in — Plum</title></svelte:head>
61
61
 
62
62
  <div class="page" data-theme={$theme}>
63
- {#if !checking}
63
+ {#if checking}
64
+ <p class="checking">Checking server…</p>
65
+ {:else}
64
66
  <div class="card">
65
67
  <div class="brand">
66
68
  <span class="brand-serif">Pl</span><span class="brand-sans">um</span>
@@ -116,6 +118,11 @@
116
118
  padding: 1rem;
117
119
  }
118
120
 
121
+ .checking {
122
+ color: var(--text-muted);
123
+ font-size: 0.875rem;
124
+ }
125
+
119
126
  .card {
120
127
  width: 100%;
121
128
  max-width: 380px;
@@ -65,7 +65,9 @@
65
65
  <svelte:head><title>Setup — Plum</title></svelte:head>
66
66
 
67
67
  <div class="page" data-theme={$theme}>
68
- {#if !checking}
68
+ {#if checking}
69
+ <p class="checking">Checking server…</p>
70
+ {:else}
69
71
  <div class="card">
70
72
  <div class="brand">
71
73
  <span class="brand-serif">Pl</span><span class="brand-sans">um</span>
@@ -136,6 +138,11 @@
136
138
  padding: 1rem;
137
139
  }
138
140
 
141
+ .checking {
142
+ color: var(--text-muted);
143
+ font-size: 0.875rem;
144
+ }
145
+
139
146
  .card {
140
147
  width: 100%;
141
148
  max-width: 400px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"