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 +19 -0
- package/CLAUDE.md +1 -1
- package/backend/lib/serverConfig.js +18 -12
- package/backend/prisma/migrations/20260707032554_add_perf_indexes/migration.sql +44 -0
- package/backend/prisma/schema.prisma +19 -0
- package/backend/routes/reports.routes.js +4 -2
- package/backend/services/reportService.js +34 -16
- package/bin/plum.js +175 -77
- package/docker-compose.yml +2 -2
- package/frontend/src/lib/api/reports.js +16 -5
- package/frontend/src/routes/login/+page.svelte +54 -39
- package/frontend/src/routes/reports/+page.svelte +32 -24
- package/frontend/src/routes/setup/+page.svelte +1 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +6 -1
- package/frontend/vite.config.js +7 -1
- package/package.json +1 -1
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 `
|
|
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.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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,
|
|
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
|
|
25
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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, [
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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 <
|
|
263
|
-
await new Promise((r) => setTimeout(r,
|
|
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(
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
clack.log.info('No users found — create your first account to get started.');
|
|
324
|
+
if (!needsSetup) return;
|
|
284
325
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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(
|
|
322
|
-
clack.log.info(`API: ${pc.cyan(
|
|
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(
|
|
427
|
+
clack.log.info(`UI: ${pc.cyan(cfg.uiUrl)}`);
|
|
332
428
|
|
|
333
|
-
|
|
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://
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
350
|
-
clack.log.info(`
|
|
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(
|
|
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');
|
package/docker-compose.yml
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
51
|
-
<div class="
|
|
52
|
-
<
|
|
53
|
-
|
|
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
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class="
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
{#if error}
|
|
99
|
+
<p class="error">{error}</p>
|
|
100
|
+
{/if}
|
|
87
101
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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(
|
|
38
|
-
$:
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
reports.forEach((r) => next.delete(r.id));
|
|
77
89
|
selected = next;
|
|
78
90
|
} else {
|
|
79
91
|
const next = new Set(selected);
|
|
80
|
-
|
|
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
|
-
{
|
|
148
|
+
{total} run{total !== 1 ? 's' : ''} recorded
|
|
137
149
|
</p>
|
|
138
150
|
</div>
|
|
139
151
|
|
|
140
|
-
{#if
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
@@ -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 ||
|
|
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) {
|
package/frontend/vite.config.js
CHANGED
|
@@ -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
|
});
|