plum-e2e 2.8.6 → 2.9.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/README.md +20 -19
- package/backend/_scaffold/utils/browser.ts +7 -63
- package/backend/_scaffold/utils/hooks.ts +5 -20
- package/backend/app.js +0 -5
- package/backend/config/scripts/generate-report.js +2 -2
- package/backend/config/scripts/run-tests.js +5 -1
- package/backend/constants/socketEvents.js +7 -4
- package/backend/lib/plumTestRuntime.js +262 -0
- package/backend/lib/reportFilename.js +1 -2
- package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
- package/backend/lib/serverBootstrap.js +14 -0
- package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
- package/backend/mcp/server.js +3 -47
- package/backend/package-lock.json +199 -1
- package/backend/package.json +3 -1
- package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
- package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
- package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
- package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
- package/backend/prisma/schema.prisma +97 -70
- package/backend/routes/backup.routes.js +47 -1
- package/backend/routes/reports.routes.js +23 -0
- package/backend/server.js +1 -1
- package/backend/services/backupCronService.js +1 -1
- package/backend/services/backupService.js +134 -44
- package/backend/services/cronService.js +23 -15
- package/backend/services/nodeExecutionService.js +41 -15
- package/backend/services/nodeStreamRegistry.js +24 -0
- package/backend/services/reportService.js +167 -83
- package/backend/services/runnerService.js +19 -7
- package/backend/services/settingsService.js +6 -3
- package/backend/services/triggerService.js +20 -13
- package/backend/websockets/nodeSocketHandler.js +40 -0
- package/backend/websockets/socketHandler.js +9 -7
- package/bin/plum.js +58 -1
- package/frontend/.svelte-kit/ambient.d.ts +28 -28
- package/frontend/.svelte-kit/generated/server/internal.js +1 -1
- package/frontend/package-lock.json +121 -32
- package/frontend/package.json +1 -0
- package/frontend/src/lib/api/reports.js +13 -4
- package/frontend/src/lib/api/settings.js +16 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
- package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
- package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
- package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
- package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
- package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
- package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
- package/frontend/src/lib/constants.js +0 -1
- package/frontend/src/lib/copy/reports.js +18 -8
- package/frontend/src/lib/copy/settings.js +25 -4
- package/frontend/src/lib/socketEvents.js +7 -4
- package/frontend/src/lib/stores/runner.js +26 -3
- package/frontend/src/lib/styles/tokens.css +7 -0
- package/frontend/src/lib/utils/format.js +108 -2
- package/frontend/src/lib/utils/inspectElement.js +34 -0
- package/frontend/src/routes/reports/+page.svelte +79 -1
- package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
- package/frontend/src/routes/reports/live/+page.svelte +236 -260
- package/frontend/src/routes/settings/+page.svelte +246 -8
- package/package.json +1 -1
- package/backend/playwright.config.js +0 -85
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
This file is part of Plum.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
*/
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
4
|
+
* it under the terms of the GNU General Public License as published by
|
|
5
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
* (at your option) any later version.
|
|
7
|
+
* Plum is distributed in the hope that it will be useful,
|
|
8
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
* GNU General Public License for more details.
|
|
11
|
+
* You should have received a copy of the GNU General Public License
|
|
12
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
13
|
+
*/
|
|
17
14
|
generator client {
|
|
18
15
|
provider = "prisma-client-js"
|
|
19
16
|
}
|
|
@@ -57,7 +54,12 @@ model Report {
|
|
|
57
54
|
status String
|
|
58
55
|
tags String
|
|
59
56
|
triggerType String @default("manual-trigger")
|
|
60
|
-
|
|
57
|
+
// runnerCount = distinct runner machines/lanes in this run (built-in counts as one).
|
|
58
|
+
// workerCount = Cucumber --parallel workers within each runner. Two independent
|
|
59
|
+
// axes of parallelism — kept as separate columns since one DB column used to mean
|
|
60
|
+
// one or the other depending on which code path wrote it.
|
|
61
|
+
runnerCount Int @default(1)
|
|
62
|
+
workerCount Int @default(1)
|
|
61
63
|
browser String @default("chromium")
|
|
62
64
|
runnerName String?
|
|
63
65
|
runnerId String?
|
|
@@ -71,6 +73,7 @@ model Report {
|
|
|
71
73
|
duration Int?
|
|
72
74
|
createdAt DateTime @default(now())
|
|
73
75
|
testHistory TestCaseHistory[]
|
|
76
|
+
recordings Recording[]
|
|
74
77
|
|
|
75
78
|
@@index([createdAt])
|
|
76
79
|
@@index([status])
|
|
@@ -79,30 +82,54 @@ model Report {
|
|
|
79
82
|
@@index([cronJobId])
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
// One row per (report, scenario, worker, browser tab) — a scenario that opens
|
|
86
|
+
// extra tabs, or runs under a multi-worker runner, produces multiple rows.
|
|
87
|
+
// `events` is a gzip-compressed JSON array of rrweb events.
|
|
88
|
+
model Recording {
|
|
89
|
+
id Int @id @default(autoincrement())
|
|
90
|
+
reportId Int
|
|
91
|
+
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
|
92
|
+
scenarioId String
|
|
93
|
+
workerId Int @default(1)
|
|
94
|
+
tabId String @default("main")
|
|
95
|
+
tabIndex Int @default(0)
|
|
96
|
+
// Epoch ms of this tab's first/last recorded event — lets the replay UI line
|
|
97
|
+
// multiple tabs up on one absolute timeline and auto-switch between them.
|
|
98
|
+
// Null on recordings written before this field existed.
|
|
99
|
+
startedAt BigInt?
|
|
100
|
+
endedAt BigInt?
|
|
101
|
+
events Bytes
|
|
102
|
+
createdAt DateTime @default(now())
|
|
103
|
+
|
|
104
|
+
@@index([reportId])
|
|
105
|
+
@@index([reportId, scenarioId])
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
model Project {
|
|
83
|
-
id
|
|
84
|
-
name
|
|
85
|
-
logoUrl
|
|
86
|
-
timezone
|
|
87
|
-
testCasePrefix
|
|
88
|
-
testSuitePrefix
|
|
89
|
-
caseSeqNext
|
|
90
|
-
suiteSeqNext
|
|
91
|
-
discordWebhookUrl
|
|
92
|
-
slackWebhookUrl
|
|
93
|
-
notifyPublicUrl
|
|
94
|
-
backupEnabled
|
|
95
|
-
backupCron
|
|
96
|
-
backupS3Endpoint
|
|
97
|
-
backupS3Region
|
|
98
|
-
backupS3Bucket
|
|
99
|
-
backupS3AccessKey
|
|
100
|
-
backupS3SecretKey
|
|
101
|
-
backupS3Prefix
|
|
102
|
-
backupLastRunAt
|
|
103
|
-
backupLastStatus
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
id Int @id @default(autoincrement())
|
|
110
|
+
name String @default("")
|
|
111
|
+
logoUrl String @default("")
|
|
112
|
+
timezone String @default("UTC")
|
|
113
|
+
testCasePrefix String @default("TC")
|
|
114
|
+
testSuitePrefix String @default("TS")
|
|
115
|
+
caseSeqNext Int @default(0)
|
|
116
|
+
suiteSeqNext Int @default(0)
|
|
117
|
+
discordWebhookUrl String @default("")
|
|
118
|
+
slackWebhookUrl String @default("")
|
|
119
|
+
notifyPublicUrl String @default("")
|
|
120
|
+
backupEnabled Boolean @default(false)
|
|
121
|
+
backupCron String @default("0 2 * * *")
|
|
122
|
+
backupS3Endpoint String @default("")
|
|
123
|
+
backupS3Region String @default("")
|
|
124
|
+
backupS3Bucket String @default("")
|
|
125
|
+
backupS3AccessKey String @default("")
|
|
126
|
+
backupS3SecretKey String @default("")
|
|
127
|
+
backupS3Prefix String @default("")
|
|
128
|
+
backupLastRunAt DateTime?
|
|
129
|
+
backupLastStatus String @default("")
|
|
130
|
+
backupIncludeReports Boolean @default(false)
|
|
131
|
+
mcpKey String @default("")
|
|
132
|
+
maxRetries Int @default(0)
|
|
106
133
|
}
|
|
107
134
|
|
|
108
135
|
model User {
|
|
@@ -117,8 +144,8 @@ model User {
|
|
|
117
144
|
suites TestSuite[]
|
|
118
145
|
cases TestCase[]
|
|
119
146
|
runs TestRun[]
|
|
120
|
-
executedEntries TestRunEntry[]
|
|
121
|
-
assignedEntries TestRunEntry[]
|
|
147
|
+
executedEntries TestRunEntry[] @relation("entryExecutor")
|
|
148
|
+
assignedEntries TestRunEntry[] @relation("entryAssignee")
|
|
122
149
|
caseHistories TestCaseHistory[]
|
|
123
150
|
}
|
|
124
151
|
|
|
@@ -138,21 +165,21 @@ model TestSuite {
|
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
model TestCase {
|
|
141
|
-
id
|
|
142
|
-
displayId
|
|
143
|
-
title
|
|
144
|
-
description
|
|
145
|
-
priority
|
|
146
|
-
isAutomated
|
|
147
|
-
suiteId
|
|
148
|
-
suite
|
|
149
|
-
createdById
|
|
150
|
-
createdBy
|
|
151
|
-
createdAt
|
|
152
|
-
updatedAt
|
|
153
|
-
steps
|
|
154
|
-
runEntries
|
|
155
|
-
history
|
|
168
|
+
id String @id @default(cuid())
|
|
169
|
+
displayId String @unique
|
|
170
|
+
title String
|
|
171
|
+
description String @default("")
|
|
172
|
+
priority String @default("Medium")
|
|
173
|
+
isAutomated Boolean @default(false)
|
|
174
|
+
suiteId String
|
|
175
|
+
suite TestSuite @relation(fields: [suiteId], references: [id], onDelete: Cascade)
|
|
176
|
+
createdById String
|
|
177
|
+
createdBy User @relation(fields: [createdById], references: [id])
|
|
178
|
+
createdAt DateTime @default(now())
|
|
179
|
+
updatedAt DateTime @updatedAt
|
|
180
|
+
steps TestStep[]
|
|
181
|
+
runEntries TestRunEntry[]
|
|
182
|
+
history TestCaseHistory[]
|
|
156
183
|
|
|
157
184
|
@@index([suiteId])
|
|
158
185
|
}
|
|
@@ -169,13 +196,13 @@ model TestStep {
|
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
model TestRun {
|
|
172
|
-
id String
|
|
199
|
+
id String @id @default(cuid())
|
|
173
200
|
title String
|
|
174
|
-
status String
|
|
201
|
+
status String @default("backlog")
|
|
175
202
|
createdById String
|
|
176
|
-
createdBy User
|
|
177
|
-
createdAt DateTime
|
|
178
|
-
updatedAt DateTime
|
|
203
|
+
createdBy User @relation(fields: [createdById], references: [id])
|
|
204
|
+
createdAt DateTime @default(now())
|
|
205
|
+
updatedAt DateTime @updatedAt
|
|
179
206
|
entries TestRunEntry[]
|
|
180
207
|
history TestCaseHistory[]
|
|
181
208
|
reports Report[]
|
|
@@ -203,19 +230,19 @@ model TestRunEntry {
|
|
|
203
230
|
}
|
|
204
231
|
|
|
205
232
|
model TestCaseHistory {
|
|
206
|
-
id String
|
|
233
|
+
id String @id @default(cuid())
|
|
207
234
|
caseId String
|
|
208
|
-
case TestCase
|
|
235
|
+
case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
209
236
|
runId String?
|
|
210
|
-
run TestRun?
|
|
237
|
+
run TestRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
|
|
211
238
|
reportId Int?
|
|
212
|
-
report Report?
|
|
239
|
+
report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull)
|
|
213
240
|
result String
|
|
214
|
-
source String
|
|
215
|
-
notes String
|
|
241
|
+
source String @default("manual")
|
|
242
|
+
notes String @default("")
|
|
216
243
|
executedById String?
|
|
217
|
-
executedBy User?
|
|
218
|
-
executedAt DateTime
|
|
244
|
+
executedBy User? @relation(fields: [executedById], references: [id])
|
|
245
|
+
executedAt DateTime @default(now())
|
|
219
246
|
|
|
220
247
|
@@index([caseId])
|
|
221
248
|
@@index([runId])
|
|
@@ -14,7 +14,8 @@ const { requireAdmin } = require('../middleware/requireAdmin');
|
|
|
14
14
|
|
|
15
15
|
router.get('/export', jwtAuth, requireAdmin, async (req, res) => {
|
|
16
16
|
try {
|
|
17
|
-
const
|
|
17
|
+
const { backupIncludeReports } = await settingsService.getBackupConfig();
|
|
18
|
+
const data = await backupService.exportAll(backupIncludeReports);
|
|
18
19
|
const fileName = `plum-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
19
20
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
20
21
|
res.setHeader('Content-Type', 'application/json');
|
|
@@ -87,6 +88,51 @@ router.post('/test-s3', jwtAuth, requireAdmin, async (req, res) => {
|
|
|
87
88
|
}
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
router.get('/s3-backups', jwtAuth, requireAdmin, async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const config = await settingsService.getProjectRaw();
|
|
94
|
+
const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
|
|
95
|
+
const missing = required.filter((k) => !config[k]);
|
|
96
|
+
if (missing.length > 0) {
|
|
97
|
+
return res
|
|
98
|
+
.status(400)
|
|
99
|
+
.json({ error: `S3 is not configured (missing: ${missing.join(', ')})` });
|
|
100
|
+
}
|
|
101
|
+
const backups = await backupService.listS3Backups(config);
|
|
102
|
+
res.json({ backups });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Failed to list S3 backups:', error);
|
|
105
|
+
res.status(500).json({ error: error.message || 'Failed to list S3 backups' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
router.post('/s3-restore', jwtAuth, requireAdmin, async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const { key } = req.body;
|
|
112
|
+
if (!key) return res.status(400).json({ error: 'Missing backup key' });
|
|
113
|
+
|
|
114
|
+
const config = await settingsService.getProjectRaw();
|
|
115
|
+
const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
|
|
116
|
+
const missing = required.filter((k) => !config[k]);
|
|
117
|
+
if (missing.length > 0) {
|
|
118
|
+
return res
|
|
119
|
+
.status(400)
|
|
120
|
+
.json({ error: `S3 is not configured (missing: ${missing.join(', ')})` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await backupService.downloadFromS3(key, config);
|
|
124
|
+
const { cronJobs, project, users, runners, testSuites, testRuns } = data;
|
|
125
|
+
await backupService.importAll(
|
|
126
|
+
{ cronJobs, project, users, runners, testSuites, testRuns },
|
|
127
|
+
cronService
|
|
128
|
+
);
|
|
129
|
+
res.json({ message: 'Restore successful' });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('S3 restore failed:', error);
|
|
132
|
+
res.status(500).json({ error: error.message || 'Restore failed' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
90
136
|
router.post('/run-now', jwtAuth, requireAdmin, async (req, res) => {
|
|
91
137
|
try {
|
|
92
138
|
await backupCronService.runBackup();
|
|
@@ -27,6 +27,29 @@ router.get('/latest', async (req, res) => {
|
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
router.get('/:id/recordings', async (req, res) => {
|
|
31
|
+
const id = parseInt(req.params.id, 10);
|
|
32
|
+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
|
33
|
+
try {
|
|
34
|
+
const recordings = await reportService.getRecordings(id);
|
|
35
|
+
res.json(recordings);
|
|
36
|
+
} catch {
|
|
37
|
+
res.status(500).json({ error: 'Failed to fetch recordings' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
router.get('/:id/recordings/:recordingId/events', async (req, res) => {
|
|
42
|
+
const recordingId = parseInt(req.params.recordingId, 10);
|
|
43
|
+
if (isNaN(recordingId)) return res.status(400).json({ error: 'Invalid recording id' });
|
|
44
|
+
try {
|
|
45
|
+
const events = await reportService.getRecordingEvents(recordingId);
|
|
46
|
+
if (!events) return res.status(404).json({ error: 'Recording not found' });
|
|
47
|
+
res.json({ events });
|
|
48
|
+
} catch {
|
|
49
|
+
res.status(500).json({ error: 'Failed to fetch recording events' });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
30
53
|
router.get('/:id', async (req, res) => {
|
|
31
54
|
const id = parseInt(req.params.id, 10);
|
|
32
55
|
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
package/backend/server.js
CHANGED
|
@@ -30,7 +30,7 @@ const port = parseInt(process.env.PORT || DEFAULT_PORT, 10);
|
|
|
30
30
|
// The underlying HTTP server, shared by Express and Socket.io.
|
|
31
31
|
const server = http.createServer(app);
|
|
32
32
|
|
|
33
|
-
// Real-time transport for live test output,
|
|
33
|
+
// Real-time transport for live test output, the rrweb stream, and run status.
|
|
34
34
|
const io = new Server(server, { cors: { origin: '*' } });
|
|
35
35
|
|
|
36
36
|
async function start() {
|
|
@@ -21,7 +21,7 @@ const runBackup = async () => {
|
|
|
21
21
|
if (!project?.backupEnabled) return;
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
|
-
const data = await backupService.exportAll();
|
|
24
|
+
const data = await backupService.exportAll(project.backupIncludeReports);
|
|
25
25
|
const key = await backupService.uploadToS3(data, project);
|
|
26
26
|
|
|
27
27
|
await prisma.project.update({
|
|
@@ -8,11 +8,33 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
|
|
|
8
8
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Export
|
|
11
|
+
// Export
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Reports (with their rrweb recordings) are opt-in — they can be large, and
|
|
15
|
+
// this used to be a hard no ("reports are too large, use pg_dump") back when
|
|
16
|
+
// screenshots lived as external files on disk. Now everything lives in
|
|
17
|
+
// Postgres, so it's just a size tradeoff the admin can choose. Recording.events
|
|
18
|
+
// is gzip-compressed BYTEA — base64 it for JSON transport; startedAt/endedAt
|
|
19
|
+
// are BigInt, which JSON.stringify can't serialize natively.
|
|
20
|
+
async function exportReports() {
|
|
21
|
+
const reports = await prisma.report.findMany({
|
|
22
|
+
orderBy: { createdAt: 'asc' },
|
|
23
|
+
include: { recordings: true }
|
|
24
|
+
});
|
|
25
|
+
return reports.map(({ recordings, ...report }) => ({
|
|
26
|
+
...report,
|
|
27
|
+
recordings: recordings.map(({ events, startedAt, endedAt, ...rec }) => ({
|
|
28
|
+
...rec,
|
|
29
|
+
events: events.toString('base64'),
|
|
30
|
+
startedAt: startedAt?.toString() ?? null,
|
|
31
|
+
endedAt: endedAt?.toString() ?? null
|
|
32
|
+
}))
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const exportAll = async (includeReports = false) => {
|
|
37
|
+
const [cronJobs, project, testSuites, testRuns, users, runners, reports] = await Promise.all([
|
|
16
38
|
prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
17
39
|
prisma.project.findUnique({ where: { id: 1 } }),
|
|
18
40
|
prisma.testSuite.findMany({
|
|
@@ -29,14 +51,16 @@ const exportAll = async () => {
|
|
|
29
51
|
include: { entries: { orderBy: { order: 'asc' } } }
|
|
30
52
|
}),
|
|
31
53
|
prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
32
|
-
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
|
|
54
|
+
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
55
|
+
includeReports ? exportReports() : Promise.resolve(null)
|
|
33
56
|
]);
|
|
34
57
|
|
|
35
58
|
return {
|
|
36
|
-
version: '
|
|
59
|
+
version: '3',
|
|
37
60
|
exportedAt: new Date().toISOString(),
|
|
38
|
-
disclaimer:
|
|
39
|
-
'Reports are
|
|
61
|
+
disclaimer: includeReports
|
|
62
|
+
? 'Reports and recordings are included in this backup.'
|
|
63
|
+
: 'Reports are not included in this backup. Enable "Include reports" in Settings → Backup, or use pg_dump on the PostgreSQL volume, to back up report history.',
|
|
40
64
|
cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
|
|
41
65
|
project: project
|
|
42
66
|
? {
|
|
@@ -62,7 +86,8 @@ const exportAll = async () => {
|
|
|
62
86
|
testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
|
|
63
87
|
...run,
|
|
64
88
|
entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
|
|
65
|
-
}))
|
|
89
|
+
})),
|
|
90
|
+
...(reports !== null && { reports })
|
|
66
91
|
};
|
|
67
92
|
};
|
|
68
93
|
|
|
@@ -71,7 +96,15 @@ const exportAll = async () => {
|
|
|
71
96
|
// ---------------------------------------------------------------------------
|
|
72
97
|
|
|
73
98
|
const importAll = async (
|
|
74
|
-
{
|
|
99
|
+
{
|
|
100
|
+
cronJobs = [],
|
|
101
|
+
project = null,
|
|
102
|
+
users = [],
|
|
103
|
+
runners = [],
|
|
104
|
+
testSuites = [],
|
|
105
|
+
testRuns = [],
|
|
106
|
+
reports = []
|
|
107
|
+
},
|
|
75
108
|
cronService
|
|
76
109
|
) => {
|
|
77
110
|
await prisma.$transaction(
|
|
@@ -186,8 +219,41 @@ const importAll = async (
|
|
|
186
219
|
});
|
|
187
220
|
}
|
|
188
221
|
}
|
|
222
|
+
|
|
223
|
+
// 7. Reports + recordings (opt-in — only present if this backup
|
|
224
|
+
// included them). Recordings are always deleted and recreated
|
|
225
|
+
// rather than upserted — same pattern as test steps above.
|
|
226
|
+
for (const report of reports) {
|
|
227
|
+
const { recordings = [], cronJobId: _staleCronJobId, ...reportData } = report;
|
|
228
|
+
|
|
229
|
+
// cronJobId can't be trusted as exported — cron jobs above are
|
|
230
|
+
// upserted keyed on taskName, not id, so the id a report recorded
|
|
231
|
+
// at export time may no longer point at the right row (or any
|
|
232
|
+
// row). Re-resolve it the same way reportService does when a
|
|
233
|
+
// report is first created: a scheduled report's triggerType is
|
|
234
|
+
// always its cron job's taskName.
|
|
235
|
+
const cronJob = reportData.triggerType
|
|
236
|
+
? await tx.cronJob.findUnique({ where: { taskName: reportData.triggerType } })
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
const data = { ...reportData, cronJobId: cronJob?.id ?? null };
|
|
240
|
+
await tx.report.upsert({ where: { id: data.id }, create: data, update: data });
|
|
241
|
+
|
|
242
|
+
await tx.recording.deleteMany({ where: { reportId: data.id } });
|
|
243
|
+
for (const rec of recordings) {
|
|
244
|
+
await tx.recording.create({
|
|
245
|
+
data: {
|
|
246
|
+
...rec,
|
|
247
|
+
reportId: data.id,
|
|
248
|
+
events: Buffer.from(rec.events, 'base64'),
|
|
249
|
+
startedAt: rec.startedAt !== null ? BigInt(rec.startedAt) : null,
|
|
250
|
+
endedAt: rec.endedAt !== null ? BigInt(rec.endedAt) : null
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
189
255
|
},
|
|
190
|
-
{ timeout:
|
|
256
|
+
{ timeout: 60000 }
|
|
191
257
|
);
|
|
192
258
|
|
|
193
259
|
if (cronService) await cronService.reload();
|
|
@@ -197,18 +263,16 @@ const importAll = async (
|
|
|
197
263
|
// S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
|
|
198
264
|
// ---------------------------------------------------------------------------
|
|
199
265
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
} = config;
|
|
211
|
-
|
|
266
|
+
// A custom endpoint means R2/B2/MinIO/on-prem, not real AWS S3 — those
|
|
267
|
+
// virtually always need path-style addressing (bucket.endpoint/... resolves
|
|
268
|
+
// nowhere for a self-hosted host like a docker service name). Real AWS S3
|
|
269
|
+
// (no custom endpoint) keeps the SDK's virtual-hosted-style default.
|
|
270
|
+
function buildS3ClientConfig({
|
|
271
|
+
backupS3Endpoint,
|
|
272
|
+
backupS3Region,
|
|
273
|
+
backupS3AccessKey,
|
|
274
|
+
backupS3SecretKey
|
|
275
|
+
}) {
|
|
212
276
|
const clientConfig = {
|
|
213
277
|
region: backupS3Region || 'auto',
|
|
214
278
|
credentials: {
|
|
@@ -216,9 +280,17 @@ const uploadToS3 = async (jsonData, config) => {
|
|
|
216
280
|
secretAccessKey: backupS3SecretKey
|
|
217
281
|
}
|
|
218
282
|
};
|
|
219
|
-
if (backupS3Endpoint)
|
|
283
|
+
if (backupS3Endpoint) {
|
|
284
|
+
clientConfig.endpoint = backupS3Endpoint;
|
|
285
|
+
clientConfig.forcePathStyle = true;
|
|
286
|
+
}
|
|
287
|
+
return clientConfig;
|
|
288
|
+
}
|
|
220
289
|
|
|
221
|
-
|
|
290
|
+
const uploadToS3 = async (jsonData, config) => {
|
|
291
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
292
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
293
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
222
294
|
|
|
223
295
|
const date = new Date().toISOString().slice(0, 10);
|
|
224
296
|
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
@@ -236,28 +308,39 @@ const uploadToS3 = async (jsonData, config) => {
|
|
|
236
308
|
return key;
|
|
237
309
|
};
|
|
238
310
|
|
|
239
|
-
|
|
240
|
-
|
|
311
|
+
// Lists backups previously uploaded by uploadToS3, newest first — the data
|
|
312
|
+
// needed for a "restore from S3" flow that doesn't require the admin to pull
|
|
313
|
+
// the file down through the S3 console/CLI themselves first.
|
|
314
|
+
const listS3Backups = async (config) => {
|
|
315
|
+
const { S3Client, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
316
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
317
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
318
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
241
319
|
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
backupS3Bucket,
|
|
246
|
-
backupS3AccessKey,
|
|
247
|
-
backupS3SecretKey,
|
|
248
|
-
backupS3Prefix
|
|
249
|
-
} = config;
|
|
320
|
+
const res = await client.send(
|
|
321
|
+
new ListObjectsV2Command({ Bucket: backupS3Bucket, Prefix: prefix })
|
|
322
|
+
);
|
|
250
323
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
324
|
+
return (res.Contents ?? [])
|
|
325
|
+
.filter((o) => o.Key.endsWith('.json'))
|
|
326
|
+
.map((o) => ({ key: o.Key, size: o.Size, lastModified: o.LastModified }))
|
|
327
|
+
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Fetches and parses one backup previously uploaded by uploadToS3.
|
|
331
|
+
const downloadFromS3 = async (key, config) => {
|
|
332
|
+
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
333
|
+
const { backupS3Bucket } = config;
|
|
334
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
335
|
+
const res = await client.send(new GetObjectCommand({ Bucket: backupS3Bucket, Key: key }));
|
|
336
|
+
const text = await res.Body.transformToString('utf8');
|
|
337
|
+
return JSON.parse(text);
|
|
338
|
+
};
|
|
259
339
|
|
|
260
|
-
|
|
340
|
+
const testS3Connection = async (config) => {
|
|
341
|
+
const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
342
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
343
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
261
344
|
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
262
345
|
const key = `${prefix}.plum-connection-test`;
|
|
263
346
|
|
|
@@ -276,4 +359,11 @@ const testS3Connection = async (config) => {
|
|
|
276
359
|
} catch {}
|
|
277
360
|
};
|
|
278
361
|
|
|
279
|
-
module.exports = {
|
|
362
|
+
module.exports = {
|
|
363
|
+
exportAll,
|
|
364
|
+
importAll,
|
|
365
|
+
uploadToS3,
|
|
366
|
+
listS3Backups,
|
|
367
|
+
downloadFromS3,
|
|
368
|
+
testS3Connection
|
|
369
|
+
};
|
|
@@ -14,7 +14,7 @@ const reportService = require('./reportService');
|
|
|
14
14
|
const settingsService = require('./settingsService');
|
|
15
15
|
const notificationService = require('./notificationService');
|
|
16
16
|
const activeRunsService = require('./activeRunsService');
|
|
17
|
-
const {
|
|
17
|
+
const { startRRwebPoller } = require('../lib/rrwebPoller');
|
|
18
18
|
const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE, TRIGGER_TYPE } = require('../constants/triggers');
|
|
19
19
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
20
20
|
const { PLUM_MODE_NODE } = require('../constants/env');
|
|
@@ -82,8 +82,14 @@ function runSingleBuiltInAttempt({ taskName, currentTag, workers, browser, suppr
|
|
|
82
82
|
|
|
83
83
|
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
84
84
|
|
|
85
|
-
const ssPoller =
|
|
86
|
-
if (_io)
|
|
85
|
+
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
86
|
+
if (_io) {
|
|
87
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
88
|
+
runId: taskName,
|
|
89
|
+
id: BUILT_IN_RUNNER_ID,
|
|
90
|
+
...batch
|
|
91
|
+
});
|
|
92
|
+
}
|
|
87
93
|
});
|
|
88
94
|
|
|
89
95
|
task.stdout.on('data', (d) => {
|
|
@@ -191,6 +197,7 @@ async function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscor
|
|
|
191
197
|
rawCucumberJson: rawJson,
|
|
192
198
|
tags,
|
|
193
199
|
triggerType: taskName,
|
|
200
|
+
workerCount: workers,
|
|
194
201
|
browser,
|
|
195
202
|
duration: Date.now() - startedAt,
|
|
196
203
|
attempts
|
|
@@ -282,6 +289,7 @@ async function runDistributed({
|
|
|
282
289
|
.saveCombinedReport({
|
|
283
290
|
reports: collectedReports,
|
|
284
291
|
runners: laneInfos,
|
|
292
|
+
workers,
|
|
285
293
|
overallCode,
|
|
286
294
|
tag: tags,
|
|
287
295
|
triggerType: taskName,
|
|
@@ -352,14 +360,14 @@ async function runDistributed({
|
|
|
352
360
|
|
|
353
361
|
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
354
362
|
|
|
355
|
-
const ssPoller =
|
|
356
|
-
if (_io)
|
|
357
|
-
_io.emit(SOCKET_EVENTS.
|
|
363
|
+
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
364
|
+
if (_io) {
|
|
365
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
358
366
|
runId: taskName,
|
|
359
|
-
laneId,
|
|
360
|
-
|
|
361
|
-
data
|
|
367
|
+
id: laneId,
|
|
368
|
+
...batch
|
|
362
369
|
});
|
|
370
|
+
}
|
|
363
371
|
});
|
|
364
372
|
|
|
365
373
|
task.stdout.on('data', (d) => {
|
|
@@ -399,14 +407,14 @@ async function runDistributed({
|
|
|
399
407
|
{ tags: currentTag, browser, workers },
|
|
400
408
|
onLog,
|
|
401
409
|
(code, content) => resolve({ code, rawJson: content ? JSON.parse(content) : [] }),
|
|
402
|
-
(
|
|
403
|
-
if (_io)
|
|
404
|
-
_io.emit(SOCKET_EVENTS.
|
|
410
|
+
(batch) => {
|
|
411
|
+
if (_io) {
|
|
412
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
405
413
|
runId: taskName,
|
|
406
|
-
laneId,
|
|
407
|
-
|
|
408
|
-
data
|
|
414
|
+
id: laneId,
|
|
415
|
+
...batch
|
|
409
416
|
});
|
|
417
|
+
}
|
|
410
418
|
}
|
|
411
419
|
);
|
|
412
420
|
});
|