plum-e2e 2.8.6 → 2.9.0

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 (57) hide show
  1. package/backend/_scaffold/utils/browser.ts +184 -30
  2. package/backend/_scaffold/utils/hooks.ts +37 -8
  3. package/backend/app.js +0 -5
  4. package/backend/config/scripts/generate-report.js +2 -2
  5. package/backend/constants/socketEvents.js +7 -4
  6. package/backend/lib/reportFilename.js +1 -2
  7. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  8. package/backend/lib/serverBootstrap.js +14 -0
  9. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  10. package/backend/mcp/server.js +3 -47
  11. package/backend/package-lock.json +199 -1
  12. package/backend/package.json +3 -1
  13. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  14. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  15. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  16. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  17. package/backend/prisma/schema.prisma +97 -70
  18. package/backend/routes/backup.routes.js +47 -1
  19. package/backend/routes/reports.routes.js +23 -0
  20. package/backend/server.js +1 -1
  21. package/backend/services/backupCronService.js +1 -1
  22. package/backend/services/backupService.js +134 -44
  23. package/backend/services/cronService.js +23 -15
  24. package/backend/services/nodeExecutionService.js +41 -15
  25. package/backend/services/nodeStreamRegistry.js +24 -0
  26. package/backend/services/reportService.js +167 -83
  27. package/backend/services/runnerService.js +19 -7
  28. package/backend/services/settingsService.js +6 -3
  29. package/backend/services/triggerService.js +20 -13
  30. package/backend/websockets/nodeSocketHandler.js +40 -0
  31. package/backend/websockets/socketHandler.js +9 -7
  32. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  33. package/frontend/package-lock.json +121 -32
  34. package/frontend/package.json +1 -0
  35. package/frontend/src/lib/api/reports.js +13 -4
  36. package/frontend/src/lib/api/settings.js +16 -1
  37. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  38. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  39. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  40. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  41. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  42. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  43. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  44. package/frontend/src/lib/constants.js +0 -1
  45. package/frontend/src/lib/copy/reports.js +18 -8
  46. package/frontend/src/lib/copy/settings.js +25 -4
  47. package/frontend/src/lib/socketEvents.js +7 -4
  48. package/frontend/src/lib/stores/runner.js +26 -3
  49. package/frontend/src/lib/styles/tokens.css +7 -0
  50. package/frontend/src/lib/utils/format.js +108 -2
  51. package/frontend/src/lib/utils/inspectElement.js +34 -0
  52. package/frontend/src/routes/reports/+page.svelte +79 -1
  53. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  54. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  55. package/frontend/src/routes/settings/+page.svelte +246 -8
  56. package/package.json +1 -1
  57. package/backend/playwright.config.js +0 -85
@@ -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
  });
@@ -8,7 +8,8 @@ const os = require('os');
8
8
  const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { spawn } = require('child_process');
11
- const { startSsPoller } = require('../lib/screenshotPoller');
11
+ const { io: ioClient } = require('socket.io-client');
12
+ const { startRRwebPoller } = require('../lib/rrwebPoller');
12
13
  const { TRIGGER_REMOTE } = require('../constants/triggers');
13
14
  const { DEFAULT_BROWSER } = require('../constants/defaults');
14
15
  const { JOB_STATUS } = require('../constants/jobStatus');
@@ -23,17 +24,40 @@ function getJob(jobId) {
23
24
  return jobs[jobId];
24
25
  }
25
26
 
27
+ // Opens this node's own outbound connection back to the primary for this job
28
+ // — logs/rrweb events are pushed the moment they happen instead of waiting to
29
+ // be polled. Uses the same token this node already validates incoming HTTP
30
+ // calls against (see authGuard), so there's no separate credential to manage.
31
+ // Best-effort: if the primary is unreachable this way, the job still runs —
32
+ // dispatchAndPoll falls back to draining logs from the HTTP poll when it
33
+ // never registered a socket relay for this jobId.
34
+ function connectPrimaryStream(primaryUrl, jobId) {
35
+ try {
36
+ const socket = ioClient(`${primaryUrl}/node-stream`, {
37
+ auth: { token: process.env.NODE_TOKEN },
38
+ reconnectionAttempts: 5,
39
+ timeout: 8000
40
+ });
41
+ socket.on('connect', () => socket.emit('join', jobId));
42
+ return socket;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
26
48
  // Starts a remote test job dispatched from the primary server: materializes
27
- // any uploaded test files, spawns `npm run test`, and tracks logs/screenshots
28
- // for later HTTP polling (see pollJob).
49
+ // any uploaded test files, spawns `npm run test`, and tracks logs for later
50
+ // HTTP polling (see pollJob).
29
51
  function startJob({
30
52
  tags,
31
53
  browser = DEFAULT_BROWSER,
32
54
  workers = 1,
33
55
  tests = null,
34
- env: userEnv = {}
56
+ env: userEnv = {},
57
+ primaryUrl = null
35
58
  }) {
36
59
  const jobId = crypto.randomUUID();
60
+ const primaryStream = primaryUrl ? connectPrimaryStream(primaryUrl, jobId) : null;
37
61
 
38
62
  // path.resolve ensures absolute even if TMPDIR env var is set to a relative path
39
63
  const tmpdir = path.resolve(os.tmpdir());
@@ -63,8 +87,7 @@ function startJob({
63
87
  meta: { tags: tags || '', browser, workers },
64
88
  tempTestsDir,
65
89
  reportFile,
66
- ssDir,
67
- pendingScreenshots: []
90
+ ssDir
68
91
  };
69
92
 
70
93
  const env = {
@@ -84,19 +107,24 @@ function startJob({
84
107
  };
85
108
  if (workers > 1) env.PARALLEL = String(workers);
86
109
 
87
- const ssPoller = startSsPoller(ssDir, (data) => {
88
- jobs[jobId]?.pendingScreenshots.push(data);
110
+ const ssPoller = startRRwebPoller(ssDir, (batch) => {
111
+ primaryStream?.emit('rrweb-batch', batch);
89
112
  });
90
113
 
91
114
  const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
92
115
  proc.stdout.on('data', (d) => {
93
- jobs[jobId].logs += d.toString();
116
+ const text = d.toString();
117
+ jobs[jobId].logs += text;
118
+ primaryStream?.emit('log', text);
94
119
  });
95
120
  proc.stderr.on('data', (d) => {
96
- jobs[jobId].logs += d.toString();
121
+ const text = d.toString();
122
+ jobs[jobId].logs += text;
123
+ primaryStream?.emit('log', text);
97
124
  });
98
125
  proc.on('close', (code) => {
99
126
  clearInterval(ssPoller);
127
+ primaryStream?.close();
100
128
  jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
101
129
  jobs[jobId].exitCode = code;
102
130
 
@@ -123,17 +151,15 @@ function startJob({
123
151
  return jobId;
124
152
  }
125
153
 
126
- // Drains and returns pending screenshots plus logs since `offset` — used by
127
- // the primary's HTTP polling loop (this node has no socket.io connection back).
154
+ // Drains and returns logs since `offset` — used by the primary's HTTP polling
155
+ // loop (this node has no socket.io connection back).
128
156
  function pollJob(jobId, offset) {
129
157
  const job = jobs[jobId];
130
158
  if (!job) return null;
131
- const screenshots = job.pendingScreenshots.splice(0);
132
159
  return {
133
160
  status: job.status,
134
161
  logs: job.logs.slice(offset),
135
- exitCode: job.exitCode,
136
- screenshots
162
+ exitCode: job.exitCode
137
163
  };
138
164
  }
139
165
 
@@ -0,0 +1,24 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+
6
+ // In-memory jobId -> relay callbacks, so an incoming node socket (which only
7
+ // knows its own jobId) can find where to forward live rrweb/log events —
8
+ // registered by whichever dispatch call (socketHandler, cronService) started
9
+ // this job and already knows the browser-facing emit target/laneId.
10
+ const relays = new Map();
11
+
12
+ function registerRelay(jobId, { onRRwebBatch, onLog }) {
13
+ relays.set(jobId, { onRRwebBatch, onLog });
14
+ }
15
+
16
+ function unregisterRelay(jobId) {
17
+ relays.delete(jobId);
18
+ }
19
+
20
+ function getRelay(jobId) {
21
+ return relays.get(jobId);
22
+ }
23
+
24
+ module.exports = { registerRelay, unregisterRelay, getRelay };