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.
Files changed (62) hide show
  1. package/README.md +20 -19
  2. package/backend/_scaffold/utils/browser.ts +7 -63
  3. package/backend/_scaffold/utils/hooks.ts +5 -20
  4. package/backend/app.js +0 -5
  5. package/backend/config/scripts/generate-report.js +2 -2
  6. package/backend/config/scripts/run-tests.js +5 -1
  7. package/backend/constants/socketEvents.js +7 -4
  8. package/backend/lib/plumTestRuntime.js +262 -0
  9. package/backend/lib/reportFilename.js +1 -2
  10. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  11. package/backend/lib/serverBootstrap.js +14 -0
  12. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  13. package/backend/mcp/server.js +3 -47
  14. package/backend/package-lock.json +199 -1
  15. package/backend/package.json +3 -1
  16. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  17. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  18. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  19. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  20. package/backend/prisma/schema.prisma +97 -70
  21. package/backend/routes/backup.routes.js +47 -1
  22. package/backend/routes/reports.routes.js +23 -0
  23. package/backend/server.js +1 -1
  24. package/backend/services/backupCronService.js +1 -1
  25. package/backend/services/backupService.js +134 -44
  26. package/backend/services/cronService.js +23 -15
  27. package/backend/services/nodeExecutionService.js +41 -15
  28. package/backend/services/nodeStreamRegistry.js +24 -0
  29. package/backend/services/reportService.js +167 -83
  30. package/backend/services/runnerService.js +19 -7
  31. package/backend/services/settingsService.js +6 -3
  32. package/backend/services/triggerService.js +20 -13
  33. package/backend/websockets/nodeSocketHandler.js +40 -0
  34. package/backend/websockets/socketHandler.js +9 -7
  35. package/bin/plum.js +58 -1
  36. package/frontend/.svelte-kit/ambient.d.ts +28 -28
  37. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  38. package/frontend/package-lock.json +121 -32
  39. package/frontend/package.json +1 -0
  40. package/frontend/src/lib/api/reports.js +13 -4
  41. package/frontend/src/lib/api/settings.js +16 -1
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  43. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  44. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  45. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  46. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  47. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  48. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  49. package/frontend/src/lib/constants.js +0 -1
  50. package/frontend/src/lib/copy/reports.js +18 -8
  51. package/frontend/src/lib/copy/settings.js +25 -4
  52. package/frontend/src/lib/socketEvents.js +7 -4
  53. package/frontend/src/lib/stores/runner.js +26 -3
  54. package/frontend/src/lib/styles/tokens.css +7 -0
  55. package/frontend/src/lib/utils/format.js +108 -2
  56. package/frontend/src/lib/utils/inspectElement.js +34 -0
  57. package/frontend/src/routes/reports/+page.svelte +79 -1
  58. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  59. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  60. package/frontend/src/routes/settings/+page.svelte +246 -8
  61. package/package.json +1 -1
  62. package/backend/playwright.config.js +0 -85
@@ -1,19 +1,16 @@
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
- */
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
- runners Int @default(1)
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 Int @id @default(autoincrement())
84
- name String @default("")
85
- logoUrl String @default("")
86
- timezone String @default("UTC")
87
- testCasePrefix String @default("TC")
88
- testSuitePrefix String @default("TS")
89
- caseSeqNext Int @default(0)
90
- suiteSeqNext Int @default(0)
91
- discordWebhookUrl String @default("")
92
- slackWebhookUrl String @default("")
93
- notifyPublicUrl String @default("")
94
- backupEnabled Boolean @default(false)
95
- backupCron String @default("0 2 * * *")
96
- backupS3Endpoint String @default("")
97
- backupS3Region String @default("")
98
- backupS3Bucket String @default("")
99
- backupS3AccessKey String @default("")
100
- backupS3SecretKey String @default("")
101
- backupS3Prefix String @default("")
102
- backupLastRunAt DateTime?
103
- backupLastStatus String @default("")
104
- mcpKey String @default("")
105
- maxRetries Int @default(0)
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[] @relation("entryExecutor")
121
- assignedEntries TestRunEntry[] @relation("entryAssignee")
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 String @id @default(cuid())
142
- displayId String @unique
143
- title String
144
- description String @default("")
145
- priority String @default("Medium")
146
- isAutomated Boolean @default(false)
147
- suiteId String
148
- suite TestSuite @relation(fields: [suiteId], references: [id], onDelete: Cascade)
149
- createdById String
150
- createdBy User @relation(fields: [createdById], references: [id])
151
- createdAt DateTime @default(now())
152
- updatedAt DateTime @updatedAt
153
- steps TestStep[]
154
- runEntries TestRunEntry[]
155
- history TestCaseHistory[]
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 @id @default(cuid())
199
+ id String @id @default(cuid())
173
200
  title String
174
- status String @default("backlog")
201
+ status String @default("backlog")
175
202
  createdById String
176
- createdBy User @relation(fields: [createdById], references: [id])
177
- createdAt DateTime @default(now())
178
- updatedAt DateTime @updatedAt
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 @id @default(cuid())
233
+ id String @id @default(cuid())
207
234
  caseId String
208
- case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
235
+ case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
209
236
  runId String?
210
- run TestRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
237
+ run TestRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
211
238
  reportId Int?
212
- report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull)
239
+ report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull)
213
240
  result String
214
- source String @default("manual")
215
- notes String @default("")
241
+ source String @default("manual")
242
+ notes String @default("")
216
243
  executedById String?
217
- executedBy User? @relation(fields: [executedById], references: [id])
218
- executedAt DateTime @default(now())
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 data = await backupService.exportAll();
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, screenshots, and run status.
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 — all data except reports (reports are too large; use pg_dump instead)
11
+ // Export
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- const exportAll = async () => {
15
- const [cronJobs, project, testSuites, testRuns, users, runners] = await Promise.all([
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: '2',
59
+ version: '3',
37
60
  exportedAt: new Date().toISOString(),
38
- disclaimer:
39
- 'Reports are not included in this backup. Use pg_dump on the PostgreSQL volume to back up report history.',
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
- { cronJobs = [], project = null, users = [], runners = [], testSuites = [], testRuns = [] },
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: 30000 }
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
- const uploadToS3 = async (jsonData, config) => {
201
- const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
202
-
203
- const {
204
- backupS3Endpoint,
205
- backupS3Region,
206
- backupS3Bucket,
207
- backupS3AccessKey,
208
- backupS3SecretKey,
209
- backupS3Prefix
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) clientConfig.endpoint = backupS3Endpoint;
283
+ if (backupS3Endpoint) {
284
+ clientConfig.endpoint = backupS3Endpoint;
285
+ clientConfig.forcePathStyle = true;
286
+ }
287
+ return clientConfig;
288
+ }
220
289
 
221
- const client = new S3Client(clientConfig);
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
- const testS3Connection = async (config) => {
240
- const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
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
- backupS3Endpoint,
244
- backupS3Region,
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
- const clientConfig = {
252
- region: backupS3Region || 'auto',
253
- credentials: {
254
- accessKeyId: backupS3AccessKey,
255
- secretAccessKey: backupS3SecretKey
256
- }
257
- };
258
- if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
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
- const client = new S3Client(clientConfig);
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 = { exportAll, importAll, uploadToS3, testS3Connection };
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 { startSsPoller } = require('../lib/screenshotPoller');
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 = startSsPoller(ssDir, ({ stepName, data }) => {
86
- if (_io) _io.emit(SOCKET_EVENTS.BG_RUN_SCREENSHOT, { runId: taskName, stepName, data });
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 = startSsPoller(ssDir, ({ stepName, data }) => {
356
- if (_io)
357
- _io.emit(SOCKET_EVENTS.BG_RUN_LANE_SCREENSHOT, {
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
- stepName,
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
- ({ stepName, data }) => {
403
- if (_io)
404
- _io.emit(SOCKET_EVENTS.BG_RUN_LANE_SCREENSHOT, {
410
+ (batch) => {
411
+ if (_io) {
412
+ _io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
405
413
  runId: taskName,
406
- laneId,
407
- stepName,
408
- data
414
+ id: laneId,
415
+ ...batch
409
416
  });
417
+ }
410
418
  }
411
419
  );
412
420
  });