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
@@ -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 };
@@ -5,12 +5,20 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const crypto = require('crypto');
8
+ const zlib = require('zlib');
9
9
  const prisma = require('./prisma');
10
10
  const { isScheduledTrigger, normaliseTrigger } = require('../constants/triggers');
11
11
  const { DEFAULT_BROWSER } = require('../constants/defaults');
12
12
  const { REPORT_STATUS } = require('../constants/jobStatus');
13
- const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
13
+
14
+ // Matched by string literal in backend/tests/utils/browser.ts (flushRecordings) —
15
+ // the two runtimes don't share a module, mirroring how 'image/png' is already
16
+ // duplicated between the two files.
17
+ const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
18
+ // Small, always-attached marker (independent of whether any tab actually
19
+ // recorded events) so a scenario's worker is always recoverable for grouping,
20
+ // even on an instant-failure scenario with an empty recording.
21
+ const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
14
22
 
15
23
  // ---------------------------------------------------------------------------
16
24
  // Auto-sync: mark test cases as automated and record history from Cucumber tags
@@ -101,22 +109,6 @@ async function resolveCronJobId(triggerType) {
101
109
  return job?.id ?? null;
102
110
  }
103
111
 
104
- /**
105
- * Walks a screenshot filename out of the content JSON tree.
106
- * Used when deleting reports to clean up screenshot files.
107
- */
108
- function collectScreenshotFiles(content) {
109
- const files = [];
110
- for (const feature of content?.features ?? []) {
111
- for (const scenario of feature?.scenarios ?? []) {
112
- for (const step of scenario?.steps ?? []) {
113
- if (step.screenshot) files.push(step.screenshot);
114
- }
115
- }
116
- }
117
- return files;
118
- }
119
-
120
112
  /**
121
113
  * Stable identity for a Cucumber feature across distributed lanes. Dispatched
122
114
  * runs report an absolute temp uri (…/plum-job-<uuid>/features/Login.feature)
@@ -139,10 +131,36 @@ function scenarioIdTag(scenario) {
139
131
  return (scenario.tags ?? []).map((t) => t.name).find(isTestCaseTag) ?? null;
140
132
  }
141
133
 
134
+ // Cucumber's legacy JSON `id` is identical for every Examples row of a Scenario
135
+ // Outline — `line` (the row's own line in the feature file) is the only field
136
+ // that actually differs between them, so combine the two for a truly unique key.
137
+ function scenarioUniqueId(scenario) {
138
+ return `${scenario.id};;${scenario.line}`;
139
+ }
140
+
142
141
  function scenarioFailed(scenario) {
143
142
  return (scenario.steps ?? []).some((s) => s.result?.status === 'failed');
144
143
  }
145
144
 
145
+ /**
146
+ * Reads the worker-id marker browser.ts attaches unconditionally in the After
147
+ * hook (separate from rrweb recordings, which can legitimately be empty).
148
+ */
149
+ function extractWorkerId(scenario) {
150
+ const marker = (scenario.steps || [])
151
+ .filter((s) => s.hidden)
152
+ .flatMap(
153
+ (step) => step.embeddings?.filter((e) => e.mime_type === WORKER_META_MIME_TYPE) ?? []
154
+ )[0];
155
+ if (!marker) return 1;
156
+ try {
157
+ const { workerId } = JSON.parse(Buffer.from(marker.data, 'base64').toString('utf8'));
158
+ return Number.isFinite(workerId) ? workerId : 1;
159
+ } catch {
160
+ return 1;
161
+ }
162
+ }
163
+
146
164
  /**
147
165
  * Test-ID tags (see scenarioIdTag) of every failed scenario in a raw Cucumber
148
166
  * JSON payload, deduped. Used to scope the next retry attempt to just the
@@ -178,7 +196,9 @@ function mergeRawAttempt(accumulated, attemptRawJson, round, attemptsMap) {
178
196
  accumulated.push(accFeature);
179
197
  }
180
198
  for (const scenario of feature.elements ?? []) {
181
- accFeature.elements = accFeature.elements.filter((e) => e.id !== scenario.id);
199
+ accFeature.elements = accFeature.elements.filter(
200
+ (e) => scenarioUniqueId(e) !== scenarioUniqueId(scenario)
201
+ );
182
202
  accFeature.elements.push(scenario);
183
203
  const idTag = scenarioIdTag(scenario);
184
204
  if (idTag) attemptsMap[idTag] = round;
@@ -187,61 +207,61 @@ function mergeRawAttempt(accumulated, attemptRawJson, round, attemptsMap) {
187
207
  return accumulated;
188
208
  }
189
209
 
190
- function deleteScreenshotFiles(content) {
191
- for (const file of collectScreenshotFiles(content)) {
192
- const p = path.join(SCREENSHOTS_DIR, file);
193
- try {
194
- if (fs.existsSync(p)) fs.unlinkSync(p);
195
- } catch {}
196
- }
210
+ /**
211
+ * Parses one scenario's hidden hook-step rrweb attachments (flushed from
212
+ * browser.ts's flushRecordings via the After hook) into Recording rows ready
213
+ * for prisma.recording.createMany, keyed to this scenario via scenarioUniqueId.
214
+ */
215
+ function extractRecordings(scenario) {
216
+ const hookRecordings = (scenario.steps || [])
217
+ .filter((s) => s.hidden)
218
+ .flatMap((step) => step.embeddings?.filter((e) => e.mime_type === RRWEB_MIME_TYPE) ?? []);
219
+
220
+ return hookRecordings
221
+ .map((embedding) => {
222
+ try {
223
+ const decompressed = zlib.gunzipSync(Buffer.from(embedding.data, 'base64'));
224
+ const { workerId, tabId, tabIndex, events, openedAt, closedAt } = JSON.parse(
225
+ decompressed.toString('utf8')
226
+ );
227
+ return {
228
+ scenarioId: scenarioUniqueId(scenario),
229
+ workerId,
230
+ tabId,
231
+ tabIndex,
232
+ // Real Playwright open/close times (browser.ts), not inferred from the
233
+ // events themselves — a static page can go quiet, or emit nothing at
234
+ // all, long before it actually closes, which made the last-event
235
+ // timestamp an unreliable stand-in for how long a tab stayed relevant.
236
+ startedAt: BigInt(openedAt ?? events[0]?.timestamp ?? 0),
237
+ endedAt: BigInt(closedAt ?? events[events.length - 1]?.timestamp ?? 0),
238
+ events: zlib.gzipSync(Buffer.from(JSON.stringify(events), 'utf8'))
239
+ };
240
+ } catch (e) {
241
+ console.error(`[report] Failed to parse rrweb recording: ${e.message}`);
242
+ return null;
243
+ }
244
+ })
245
+ .filter(Boolean);
197
246
  }
198
247
 
199
248
  /**
200
249
  * Transforms raw Cucumber JSON into our stored format:
201
250
  * - Resolves pass/fail status per step/scenario/feature
202
- * - Extracts base64 screenshots to PNG files on disk
203
- * - Stores only the filename in the content (not the base64 blob)
251
+ * - Extracts rrweb recordings attached via the After hook
204
252
  *
205
- * Returns { features, status } where status is 'PASS' | 'FAIL'.
253
+ * Returns { features, status, recordings } where status is 'PASS' | 'FAIL'.
206
254
  */
207
255
  function processCucumberJson(raw, attempts = {}) {
208
- fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
256
+ const recordings = [];
209
257
 
210
258
  const features = raw.map((feature) => {
211
259
  const scenarios = (feature.elements || []).map((scenario) => {
260
+ recordings.push(...extractRecordings(scenario));
261
+
212
262
  const visibleSteps = (scenario.steps || []).filter((s) => !s.hidden);
213
- const hookScreenshots = (scenario.steps || [])
214
- .filter((s) => s.hidden)
215
- .flatMap((step) => step.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []);
216
- const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
217
-
218
- const steps = visibleSteps.map((step, index) => {
219
- // AfterStep hook attachments land in step.after[].embeddings in Cucumber.js JSON
220
- const afterStepScreenshot =
221
- (step.after ?? []).flatMap(
222
- (a) => a.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []
223
- )[0]?.data ?? null;
224
-
225
- const screenshotData =
226
- step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
227
- afterStepScreenshot ??
228
- (index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
229
- null;
230
-
231
- let screenshotFile = null;
232
- if (screenshotData) {
233
- screenshotFile = `${crypto.randomUUID()}.png`;
234
- try {
235
- fs.writeFileSync(
236
- path.join(SCREENSHOTS_DIR, screenshotFile),
237
- Buffer.from(screenshotData, 'base64')
238
- );
239
- } catch (e) {
240
- console.error(`[report] Failed to write screenshot: ${e.message}`);
241
- screenshotFile = null;
242
- }
243
- }
244
263
 
264
+ const steps = visibleSteps.map((step) => {
245
265
  const rawStatus = step.result?.status ?? 'pending';
246
266
  // Undefined/ambiguous steps rank below 'failed' otherwise, so a step
247
267
  // definition mismatch reports as passing instead of failing.
@@ -254,7 +274,7 @@ function processCucumberJson(raw, attempts = {}) {
254
274
  status,
255
275
  duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
256
276
  error: step.result?.error_message ?? null,
257
- screenshot: screenshotFile
277
+ dataTable: step.arguments?.[0]?.rows?.map((row) => row.cells) ?? null
258
278
  };
259
279
  });
260
280
 
@@ -264,12 +284,15 @@ function processCucumberJson(raw, attempts = {}) {
264
284
  }, 'passed');
265
285
 
266
286
  return {
287
+ id: scenarioUniqueId(scenario),
267
288
  name: scenario.name,
268
289
  keyword: scenario.keyword,
269
290
  tags: (scenario.tags ?? []).map((t) => t.name),
270
291
  status: worstStatus,
271
292
  duration: steps.reduce((s, st) => s + st.duration, 0),
272
293
  attempts: attempts[scenarioIdTag(scenario)] ?? 1,
294
+ workerId: extractWorkerId(scenario),
295
+ runnerName: scenario.__plumRunnerName ?? null,
273
296
  steps
274
297
  };
275
298
  });
@@ -283,7 +306,7 @@ function processCucumberJson(raw, attempts = {}) {
283
306
  });
284
307
 
285
308
  const hasFailures = features.some((f) => f.status === 'failed');
286
- return { features, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
309
+ return { features, recordings, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
287
310
  }
288
311
 
289
312
  // ---------------------------------------------------------------------------
@@ -295,7 +318,8 @@ const reportListSelect = {
295
318
  status: true,
296
319
  tags: true,
297
320
  triggerType: true,
298
- runners: true,
321
+ runnerCount: true,
322
+ workerCount: true,
299
323
  browser: true,
300
324
  runnerName: true,
301
325
  createdAt: true,
@@ -340,7 +364,8 @@ const getReportDetail = async (id) => {
340
364
  status: true,
341
365
  tags: true,
342
366
  triggerType: true,
343
- runners: true,
367
+ runnerCount: true,
368
+ workerCount: true,
344
369
  browser: true,
345
370
  runnerName: true,
346
371
  createdAt: true,
@@ -355,6 +380,47 @@ const getReportDetail = async (id) => {
355
380
  return { ...meta, features: content?.features ?? [] };
356
381
  };
357
382
 
383
+ /**
384
+ * Metadata for every recording on a report — deliberately excludes `events`
385
+ * (can be large) so the replay UI can work out tab timing/order before
386
+ * fetching any actual event data.
387
+ */
388
+ const getRecordings = async (reportId) => {
389
+ const recordings = await prisma.recording.findMany({
390
+ where: { reportId },
391
+ select: {
392
+ id: true,
393
+ scenarioId: true,
394
+ workerId: true,
395
+ tabId: true,
396
+ tabIndex: true,
397
+ startedAt: true,
398
+ endedAt: true
399
+ },
400
+ orderBy: { tabIndex: 'asc' }
401
+ });
402
+ // BigInt doesn't survive JSON.stringify — both fit safely in a JS Number
403
+ // (epoch ms is well under Number.MAX_SAFE_INTEGER).
404
+ return recordings.map((r) => ({
405
+ ...r,
406
+ startedAt: r.startedAt === null ? null : Number(r.startedAt),
407
+ endedAt: r.endedAt === null ? null : Number(r.endedAt)
408
+ }));
409
+ };
410
+
411
+ /**
412
+ * Decompresses one recording's rrweb event array, fetched lazily by the
413
+ * replay UI only once a tab is actually selected for playback.
414
+ */
415
+ const getRecordingEvents = async (recordingId) => {
416
+ const recording = await prisma.recording.findUnique({
417
+ where: { id: recordingId },
418
+ select: { events: true }
419
+ });
420
+ if (!recording) return null;
421
+ return JSON.parse(zlib.gunzipSync(recording.events).toString('utf8'));
422
+ };
423
+
358
424
  // ---------------------------------------------------------------------------
359
425
  // Write operations
360
426
  // ---------------------------------------------------------------------------
@@ -366,7 +432,8 @@ const getReportDetail = async (id) => {
366
432
  * rawCucumberJson: object[],
367
433
  * tags: string,
368
434
  * triggerType: string,
369
- * nodeCount?: number,
435
+ * runnerCount?: number,
436
+ * workerCount?: number,
370
437
  * browser?: string,
371
438
  * runnerName?: string,
372
439
  * runnerId?: string,
@@ -377,7 +444,8 @@ const saveReport = async ({
377
444
  rawCucumberJson,
378
445
  tags,
379
446
  triggerType,
380
- nodeCount,
447
+ runnerCount = 1,
448
+ workerCount = 1,
381
449
  browser,
382
450
  runnerName,
383
451
  runnerId,
@@ -388,7 +456,11 @@ const saveReport = async ({
388
456
  attempts = {}
389
457
  }) => {
390
458
  const normTrigger = normaliseTrigger(triggerType);
391
- const { features, status: derivedStatus } = processCucumberJson(rawCucumberJson, attempts);
459
+ const {
460
+ features,
461
+ recordings,
462
+ status: derivedStatus
463
+ } = processCucumberJson(rawCucumberJson, attempts);
392
464
  const status = forceFail ? REPORT_STATUS.FAIL : derivedStatus;
393
465
  const cronJobId = await resolveCronJobId(normTrigger);
394
466
 
@@ -397,7 +469,8 @@ const saveReport = async ({
397
469
  status,
398
470
  tags: (tags ?? '').replace(/^\(|\)$/g, '') || '@all-tests',
399
471
  triggerType: normTrigger,
400
- runners: nodeCount ?? 1,
472
+ runnerCount,
473
+ workerCount,
401
474
  browser: browser ?? DEFAULT_BROWSER,
402
475
  runnerName: runnerName ?? null,
403
476
  runnerId: runnerId ?? null,
@@ -408,17 +481,23 @@ const saveReport = async ({
408
481
  duration
409
482
  }
410
483
  });
484
+ if (recordings.length > 0) {
485
+ await prisma.recording.createMany({
486
+ data: recordings.map((r) => ({ ...r, reportId: report.id }))
487
+ });
488
+ }
411
489
  syncAutomatedTags(report.id, features, testRunId ?? null);
412
490
  return report;
413
491
  };
414
492
 
415
493
  /**
416
- * Merges Cucumber JSON from all distributed lanes, processes screenshots,
417
- * and persists one combined report to the database.
494
+ * Merges Cucumber JSON from all distributed lanes and persists one combined
495
+ * report to the database.
418
496
  *
419
497
  * @param {{
420
498
  * reports: (string|null)[],
421
499
  * runners: { id: string, name: string, dbId: string|null }[],
500
+ * workers?: number,
422
501
  * overallCode: number,
423
502
  * tag: string,
424
503
  * triggerType: string,
@@ -429,6 +508,7 @@ const saveReport = async ({
429
508
  const saveCombinedReport = async ({
430
509
  reports,
431
510
  runners,
511
+ workers = 1,
432
512
  overallCode,
433
513
  tag,
434
514
  triggerType,
@@ -439,15 +519,23 @@ const saveCombinedReport = async ({
439
519
  attemptsByLane = null
440
520
  }) => {
441
521
  const featureMap = new Map();
442
- for (const content of reports) {
443
- if (!content) continue;
522
+ reports.forEach((content, laneIdx) => {
523
+ if (!content) return;
444
524
  let parsed;
445
525
  try {
446
526
  parsed = JSON.parse(content);
447
527
  } catch {
448
- continue;
528
+ return;
449
529
  }
530
+ const lane = runners[laneIdx];
450
531
  for (const feature of parsed) {
532
+ // Merging loses which lane a scenario came from — the raw Cucumber JSON
533
+ // has no such field — so stamp it here, before the merge, while we still
534
+ // know. processCucumberJson reads this to group the report's scenario
535
+ // list by runner.
536
+ for (const scenario of feature.elements ?? []) {
537
+ scenario.__plumRunnerName = lane?.name ?? null;
538
+ }
451
539
  // Each lane runs from its own temp dir, so the same feature reports a
452
540
  // different absolute uri per runner. Key on the path from `features/`
453
541
  // onward (falling back to name) so one feature's scenarios from every
@@ -459,7 +547,7 @@ const saveCombinedReport = async ({
459
547
  featureMap.set(key, { ...feature, elements: [...(feature.elements ?? [])] });
460
548
  }
461
549
  }
462
- }
550
+ });
463
551
  const combined = [...featureMap.values()];
464
552
 
465
553
  let combinedLogs = null;
@@ -478,7 +566,8 @@ const saveCombinedReport = async ({
478
566
  rawCucumberJson: combined,
479
567
  tags: tag,
480
568
  triggerType,
481
- nodeCount: runners.length,
569
+ runnerCount: runners.length,
570
+ workerCount: workers,
482
571
  browser,
483
572
  runnerName: runners.map((r) => r.name).join(', '),
484
573
  runnerId: null,
@@ -508,17 +597,10 @@ const attachDurationToLatestReport = async ({ afterTimestamp, duration }) => {
508
597
  // ---------------------------------------------------------------------------
509
598
 
510
599
  const deleteReport = async (id) => {
511
- const report = await prisma.report.findUnique({ where: { id }, select: { content: true } });
512
- if (report) deleteScreenshotFiles(report.content);
513
600
  await prisma.report.delete({ where: { id } });
514
601
  };
515
602
 
516
603
  const deleteReports = async (ids) => {
517
- const reports = await prisma.report.findMany({
518
- where: { id: { in: ids } },
519
- select: { content: true }
520
- });
521
- for (const r of reports) deleteScreenshotFiles(r.content);
522
604
  await prisma.report.deleteMany({ where: { id: { in: ids } } });
523
605
  };
524
606
 
@@ -546,6 +628,8 @@ module.exports = {
546
628
  getReports,
547
629
  getLatestReportId,
548
630
  getReportDetail,
631
+ getRecordings,
632
+ getRecordingEvents,
549
633
  saveReport,
550
634
  saveCombinedReport,
551
635
  attachDurationToLatestReport,
@@ -11,6 +11,8 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
11
11
  const { DEFAULT_BROWSER } = require('../constants/defaults');
12
12
  const { bearerHeader } = require('../lib/authHeader');
13
13
  const { JOB_STATUS } = require('../constants/jobStatus');
14
+ const settingsService = require('./settingsService');
15
+ const nodeStreamRegistry = require('./nodeStreamRegistry');
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Runner CRUD
@@ -198,7 +200,7 @@ async function dispatchAndPoll(
198
200
  { tags, browser, workers },
199
201
  onLog,
200
202
  onDone,
201
- onScreenshot = null
203
+ onRRwebBatch = null
202
204
  ) {
203
205
  // The async poll callback can overlap if a tick takes longer than the interval;
204
206
  // guard so the run resolves exactly once and can't be finalised while a lane
@@ -207,6 +209,7 @@ async function dispatchAndPoll(
207
209
  const finish = (code, content) => {
208
210
  if (settled) return;
209
211
  settled = true;
212
+ if (jobId) nodeStreamRegistry.unregisterRelay(jobId);
210
213
  onDone(code, content);
211
214
  };
212
215
 
@@ -217,6 +220,12 @@ async function dispatchAndPoll(
217
220
  return;
218
221
  }
219
222
 
223
+ // The node needs to know where to open its own socket back to us — without
224
+ // this it has no way to reach the primary, since today only the reverse
225
+ // (primary knowing each node's url) is configured.
226
+ const { notifyPublicUrl } = await settingsService.getWebhooks();
227
+ const primaryUrl = notifyPublicUrl ? notifyPublicUrl.replace(/\/$/, '') : null;
228
+
220
229
  let jobId;
221
230
  try {
222
231
  const res = await fetch(`${runner.url}/api/execute`, {
@@ -230,7 +239,8 @@ async function dispatchAndPoll(
230
239
  browser,
231
240
  workers,
232
241
  tests: collectTestFiles(),
233
- env: loadTestEnv(process.cwd())
242
+ env: loadTestEnv(process.cwd()),
243
+ ...(primaryUrl ? { primaryUrl } : {})
234
244
  }),
235
245
  signal: AbortSignal.timeout(10000)
236
246
  });
@@ -244,6 +254,12 @@ async function dispatchAndPoll(
244
254
 
245
255
  onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
246
256
 
257
+ // With a primaryUrl configured, the node streams logs/rrweb events over its
258
+ // own socket instead — draining them from the poll too would duplicate them.
259
+ if (primaryUrl) {
260
+ nodeStreamRegistry.registerRelay(jobId, { onRRwebBatch, onLog });
261
+ }
262
+
247
263
  let logOffset = 0;
248
264
  let polling = false;
249
265
  const poll = setInterval(async () => {
@@ -257,15 +273,11 @@ async function dispatchAndPoll(
257
273
  if (!res.ok) return;
258
274
  const body = await res.json();
259
275
 
260
- if (body.logs) {
276
+ if (!primaryUrl && body.logs) {
261
277
  onLog(body.logs);
262
278
  logOffset += body.logs.length;
263
279
  }
264
280
 
265
- if (onScreenshot && Array.isArray(body.screenshots)) {
266
- for (const ss of body.screenshots) onScreenshot(ss);
267
- }
268
-
269
281
  if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
270
282
  clearInterval(poll);
271
283
  const content = await fetchReportContent(runner, jobId, onLog);
@@ -97,7 +97,8 @@ const getBackupConfig = async () => {
97
97
  backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
98
98
  backupS3Prefix: project.backupS3Prefix,
99
99
  backupLastRunAt: project.backupLastRunAt,
100
- backupLastStatus: project.backupLastStatus
100
+ backupLastStatus: project.backupLastStatus,
101
+ backupIncludeReports: project.backupIncludeReports
101
102
  };
102
103
  };
103
104
 
@@ -109,7 +110,8 @@ const updateBackupConfig = async ({
109
110
  backupS3Bucket,
110
111
  backupS3AccessKey,
111
112
  backupS3SecretKey,
112
- backupS3Prefix
113
+ backupS3Prefix,
114
+ backupIncludeReports
113
115
  }) => {
114
116
  const update = {
115
117
  ...(backupEnabled !== undefined && { backupEnabled }),
@@ -119,7 +121,8 @@ const updateBackupConfig = async ({
119
121
  ...(backupS3Bucket !== undefined && { backupS3Bucket }),
120
122
  ...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
121
123
  ...(backupS3SecretKey && { backupS3SecretKey }),
122
- ...(backupS3Prefix !== undefined && { backupS3Prefix })
124
+ ...(backupS3Prefix !== undefined && { backupS3Prefix }),
125
+ ...(backupIncludeReports !== undefined && { backupIncludeReports })
123
126
  };
124
127
  return prisma.project.upsert({
125
128
  where: { id: 1 },