plum-e2e 2.4.13 → 2.5.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 (55) hide show
  1. package/CLAUDE.md +1 -1
  2. package/backend/_scaffold/utils/browser.ts +47 -2
  3. package/backend/_scaffold/utils/hooks.ts +25 -2
  4. package/backend/config/scripts/run-tests.js +35 -13
  5. package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
  6. package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
  7. package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
  8. package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
  9. package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
  10. package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
  11. package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
  12. package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
  13. package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
  14. package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
  15. package/backend/prisma/migrations/20260707032554_add_perf_indexes/migration.sql +44 -0
  16. package/backend/prisma/schema.prisma +20 -0
  17. package/backend/routes/node.routes.js +42 -4
  18. package/backend/routes/reports.routes.js +4 -2
  19. package/backend/services/reportService.js +58 -20
  20. package/backend/services/runnerService.js +11 -1
  21. package/backend/websockets/socketHandler.js +162 -16
  22. package/bin/plum.js +96 -68
  23. package/frontend/package.json +1 -1
  24. package/frontend/src/lib/api/reports.js +16 -5
  25. package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
  26. package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
  27. package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
  28. package/frontend/src/lib/components/ui/Badge.svelte +1 -1
  29. package/frontend/src/lib/components/ui/Button.svelte +1 -1
  30. package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
  31. package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
  32. package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
  33. package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
  34. package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
  35. package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
  36. package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
  37. package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
  38. package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
  39. package/frontend/src/lib/constants.js +14 -0
  40. package/frontend/src/lib/stores/runner.js +5 -3
  41. package/frontend/src/lib/styles/tokens.css +26 -0
  42. package/frontend/src/lib/utils/format.js +74 -0
  43. package/frontend/src/routes/+page.svelte +3 -3
  44. package/frontend/src/routes/login/+page.svelte +55 -40
  45. package/frontend/src/routes/reports/+page.svelte +35 -27
  46. package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
  47. package/frontend/src/routes/reports/live/+page.svelte +281 -283
  48. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
  49. package/frontend/src/routes/settings/+page.svelte +6 -6
  50. package/frontend/src/routes/setup/+page.svelte +2 -1
  51. package/frontend/src/routes/test-repository/+page.svelte +5 -5
  52. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +33 -152
  53. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
  54. package/frontend/vite.config.js +7 -1
  55. package/package.json +1 -1
@@ -50,10 +50,13 @@ router.post('/execute', authGuard, (req, res) => {
50
50
  const { tags, browser = 'chromium', workers = 1, tests = null } = req.body;
51
51
  const jobId = crypto.randomUUID();
52
52
 
53
+ // path.resolve ensures absolute even if TMPDIR env var is set to a relative path
54
+ const tmpdir = path.resolve(os.tmpdir());
55
+
53
56
  // Write test files sent by the primary into a per-job temp dir
54
57
  let tempTestsDir = null;
55
58
  if (tests && Object.keys(tests).length > 0) {
56
- tempTestsDir = path.join(os.tmpdir(), `plum-job-${jobId}`);
59
+ tempTestsDir = path.join(tmpdir, `plum-job-${jobId}`);
57
60
  for (const [rel, content] of Object.entries(tests)) {
58
61
  const dest = path.join(tempTestsDir, rel);
59
62
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -63,7 +66,9 @@ router.post('/execute', authGuard, (req, res) => {
63
66
 
64
67
  // Each job writes to its own temp file so concurrent jobs on the same node
65
68
  // cannot clobber each other's reports (shared cucumber_report.json race condition).
66
- const reportFile = path.join(os.tmpdir(), `plum-report-${jobId}.json`);
69
+ const reportFile = path.join(tmpdir, `plum-report-${jobId}.json`);
70
+ const ssDir = path.join(tmpdir, `plum-ss-${jobId}`);
71
+ fs.mkdirSync(ssDir, { recursive: true });
67
72
 
68
73
  jobs[jobId] = {
69
74
  status: 'running',
@@ -72,7 +77,9 @@ router.post('/execute', authGuard, (req, res) => {
72
77
  startedAt: Date.now(),
73
78
  meta: { tags: tags || '', browser, workers },
74
79
  tempTestsDir,
75
- reportFile
80
+ reportFile,
81
+ ssDir,
82
+ pendingScreenshots: []
76
83
  };
77
84
 
78
85
  const env = {
@@ -82,10 +89,36 @@ router.post('/execute', authGuard, (req, res) => {
82
89
  BROWSER: browser,
83
90
  REPORT_RUNNERS: String(workers),
84
91
  CUCUMBER_REPORT_FILE: reportFile,
92
+ PLUM_SS_DIR: ssDir,
85
93
  ...(tempTestsDir ? { TESTS_ROOT: tempTestsDir } : {})
86
94
  };
87
95
  if (workers > 1) env.PARALLEL = String(workers);
88
96
 
97
+ const seenSsFiles = new Set();
98
+ const ssPoller = setInterval(() => {
99
+ const job = jobs[jobId];
100
+ if (!job) {
101
+ clearInterval(ssPoller);
102
+ return;
103
+ }
104
+ try {
105
+ const files = fs
106
+ .readdirSync(ssDir)
107
+ .filter((f) => f.endsWith('.ss.json'))
108
+ .sort();
109
+ for (const f of files) {
110
+ if (seenSsFiles.has(f)) continue;
111
+ seenSsFiles.add(f);
112
+ const filePath = path.join(ssDir, f);
113
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
114
+ job.pendingScreenshots.push(data);
115
+ try {
116
+ fs.unlinkSync(filePath);
117
+ } catch {}
118
+ }
119
+ } catch {}
120
+ }, 400);
121
+
89
122
  const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
90
123
  proc.stdout.on('data', (d) => {
91
124
  jobs[jobId].logs += d.toString();
@@ -94,6 +127,7 @@ router.post('/execute', authGuard, (req, res) => {
94
127
  jobs[jobId].logs += d.toString();
95
128
  });
96
129
  proc.on('close', (code) => {
130
+ clearInterval(ssPoller);
97
131
  jobs[jobId].status = code === 0 ? 'done' : 'error';
98
132
  jobs[jobId].exitCode = code;
99
133
 
@@ -103,6 +137,8 @@ router.post('/execute', authGuard, (req, res) => {
103
137
  }
104
138
  } catch {}
105
139
 
140
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
141
+
106
142
  if (jobs[jobId].tempTestsDir) {
107
143
  fs.rm(jobs[jobId].tempTestsDir, { recursive: true, force: true }, () => {});
108
144
  }
@@ -132,10 +168,12 @@ router.get('/execute/:jobId', authGuard, (req, res) => {
132
168
  if (!job) return res.status(404).json({ error: 'Job not found' });
133
169
 
134
170
  const offset = parseInt(req.query.offset || '0', 10);
171
+ const screenshots = job.pendingScreenshots.splice(0);
135
172
  res.json({
136
173
  status: job.status,
137
174
  logs: job.logs.slice(offset),
138
- exitCode: job.exitCode
175
+ exitCode: job.exitCode,
176
+ screenshots
139
177
  });
140
178
  });
141
179
 
@@ -21,8 +21,10 @@ const reportService = require('../services/reportService');
21
21
 
22
22
  router.get('/', async (req, res) => {
23
23
  try {
24
- const reports = await reportService.getAllReports();
25
- res.json({ reports });
24
+ const page = Math.max(1, parseInt(req.query.page) || 1);
25
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 15));
26
+ const result = await reportService.getReports({ page, limit });
27
+ res.json(result);
26
28
  } catch {
27
29
  res.status(500).json({ error: 'Failed to fetch reports' });
28
30
  }
@@ -168,8 +168,15 @@ function processCucumberJson(raw) {
168
168
  const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
169
169
 
170
170
  const steps = visibleSteps.map((step, index) => {
171
+ // AfterStep hook attachments land in step.after[].embeddings in Cucumber.js JSON
172
+ const afterStepScreenshot =
173
+ (step.after ?? []).flatMap(
174
+ (a) => a.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []
175
+ )[0]?.data ?? null;
176
+
171
177
  const screenshotData =
172
178
  step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
179
+ afterStepScreenshot ??
173
180
  (index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
174
181
  null;
175
182
 
@@ -228,21 +235,39 @@ function processCucumberJson(raw) {
228
235
  // Read operations
229
236
  // ---------------------------------------------------------------------------
230
237
 
231
- const getAllReports = () =>
232
- prisma.report.findMany({
233
- orderBy: { createdAt: 'desc' },
234
- select: {
235
- id: true,
236
- status: true,
237
- tags: true,
238
- triggerType: true,
239
- runners: true,
240
- browser: true,
241
- runnerName: true,
242
- createdAt: true,
243
- testRun: { select: { id: true, title: true } }
244
- }
245
- });
238
+ const reportListSelect = {
239
+ id: true,
240
+ status: true,
241
+ tags: true,
242
+ triggerType: true,
243
+ runners: true,
244
+ browser: true,
245
+ runnerName: true,
246
+ createdAt: true,
247
+ testRun: { select: { id: true, title: true } }
248
+ };
249
+
250
+ const TREND_SIZE = 12;
251
+
252
+ const getReports = async ({ page = 1, limit = 15 } = {}) => {
253
+ const skip = (page - 1) * limit;
254
+ const [reports, total, passCount, trend] = await Promise.all([
255
+ prisma.report.findMany({
256
+ orderBy: { createdAt: 'desc' },
257
+ skip,
258
+ take: limit,
259
+ select: reportListSelect
260
+ }),
261
+ prisma.report.count(),
262
+ prisma.report.count({ where: { status: 'PASS' } }),
263
+ prisma.report.findMany({
264
+ orderBy: { createdAt: 'desc' },
265
+ take: TREND_SIZE,
266
+ select: { id: true, status: true, tags: true, createdAt: true }
267
+ })
268
+ ]);
269
+ return { reports, total, passCount, failCount: total - passCount, trend };
270
+ };
246
271
 
247
272
  const getLatestReportId = async () => {
248
273
  const report = await prisma.report.findFirst({
@@ -265,6 +290,7 @@ const getReportDetail = async (id) => {
265
290
  runnerName: true,
266
291
  createdAt: true,
267
292
  content: true,
293
+ logs: true,
268
294
  testRun: { select: { id: true, title: true } }
269
295
  }
270
296
  });
@@ -300,7 +326,8 @@ const saveReport = async ({
300
326
  runnerName,
301
327
  runnerId,
302
328
  testRunId,
303
- forceFail = false
329
+ forceFail = false,
330
+ logs = null
304
331
  }) => {
305
332
  const normTrigger = normaliseTrigger(triggerType);
306
333
  const { features, status: derivedStatus } = processCucumberJson(rawCucumberJson);
@@ -318,7 +345,8 @@ const saveReport = async ({
318
345
  runnerId: runnerId ?? null,
319
346
  cronJobId,
320
347
  testRunId: testRunId ?? null,
321
- content: { features }
348
+ content: { features },
349
+ logs: logs || null
322
350
  }
323
351
  });
324
352
  syncAutomatedTags(report.id, features, testRunId ?? null);
@@ -345,7 +373,8 @@ const saveCombinedReport = async ({
345
373
  tag,
346
374
  triggerType,
347
375
  browser,
348
- testRunId
376
+ testRunId,
377
+ laneLogs = null
349
378
  }) => {
350
379
  const featureMap = new Map();
351
380
  for (const content of reports) {
@@ -371,6 +400,14 @@ const saveCombinedReport = async ({
371
400
  }
372
401
  const combined = [...featureMap.values()];
373
402
 
403
+ let combinedLogs = null;
404
+ if (laneLogs) {
405
+ const parts = runners
406
+ .map((r) => (laneLogs[r.id] ? `=== ${r.name} ===\n${laneLogs[r.id]}` : null))
407
+ .filter(Boolean);
408
+ if (parts.length > 0) combinedLogs = parts.join('\n\n');
409
+ }
410
+
374
411
  return saveReport({
375
412
  rawCucumberJson: combined,
376
413
  tags: tag,
@@ -380,7 +417,8 @@ const saveCombinedReport = async ({
380
417
  runnerName: runners.map((r) => r.name).join(', '),
381
418
  runnerId: null,
382
419
  testRunId: testRunId ?? null,
383
- forceFail: reports.some((r) => r === null)
420
+ forceFail: reports.some((r) => r === null),
421
+ logs: combinedLogs
384
422
  });
385
423
  };
386
424
 
@@ -424,7 +462,7 @@ async function syncAutomatedFromFeatures() {
424
462
  }
425
463
 
426
464
  module.exports = {
427
- getAllReports,
465
+ getReports,
428
466
  getLatestReportId,
429
467
  getReportDetail,
430
468
  saveReport,
@@ -138,7 +138,13 @@ async function fetchReportContent(runner, jobId, onLog) {
138
138
  * @param {(log: string) => void} onLog Called with each new log chunk
139
139
  * @param {(exitCode: number, reportContent: string|null) => void} onDone
140
140
  */
141
- async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDone) {
141
+ async function dispatchAndPoll(
142
+ runnerId,
143
+ { tags, browser, workers },
144
+ onLog,
145
+ onDone,
146
+ onScreenshot = null
147
+ ) {
142
148
  // The async poll callback can overlap if a tick takes longer than the interval;
143
149
  // guard so the run resolves exactly once and can't be finalised while a lane
144
150
  // is still in flight.
@@ -195,6 +201,10 @@ async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDo
195
201
  logOffset += body.logs.length;
196
202
  }
197
203
 
204
+ if (onScreenshot && Array.isArray(body.screenshots)) {
205
+ for (const ss of body.screenshots) onScreenshot(ss);
206
+ }
207
+
198
208
  if (body.status === 'done' || body.status === 'error') {
199
209
  clearInterval(poll);
200
210
  const content = await fetchReportContent(runner, jobId, onLog);
@@ -16,12 +16,16 @@
16
16
  */
17
17
 
18
18
  const { spawn } = require('child_process');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
19
22
  const runnerService = require('../services/runnerService');
20
23
  const reportService = require('../services/reportService');
21
24
  const notificationService = require('../services/notificationService');
22
25
  const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
23
26
  const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
24
27
  const { readCucumberReportFile } = require('../lib/reportFilename');
28
+ const { getTestSuites } = require('../services/testService');
25
29
  const prisma = require('../services/prisma');
26
30
 
27
31
  const socketHandler = (io) => {
@@ -110,10 +114,80 @@ const socketHandler = (io) => {
110
114
  });
111
115
  };
112
116
 
117
+ // ---------------------------------------------------------------------------
118
+ // Helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function makeSyntheticFailReport(laneName, testIds, reason) {
122
+ // Build id → scenario title from local feature files so names match the real report.
123
+ const nameMap = {};
124
+ try {
125
+ const { suites } = getTestSuites();
126
+ for (const suite of suites) {
127
+ for (const test of suite.tests) {
128
+ for (const id of Array.isArray(test.id) ? test.id : [test.id]) {
129
+ nameMap[id] = test.testCase;
130
+ }
131
+ }
132
+ }
133
+ } catch {}
134
+
135
+ return JSON.stringify([
136
+ {
137
+ id: 'runner-error',
138
+ uri: 'runner-error',
139
+ name: `Runner: ${laneName}`,
140
+ keyword: 'Feature',
141
+ elements: testIds.map((id) => ({
142
+ id: id.replace(/^@/, '').toLowerCase(),
143
+ // Use real scenario title; fall back to bare ID without @
144
+ name: nameMap[id] || id.replace(/^@/, ''),
145
+ keyword: 'Scenario',
146
+ type: 'scenario',
147
+ // ids from getTestIdsForTag already carry the @ — don't add a second one
148
+ tags: [{ name: id.startsWith('@') ? id : `@${id}` }],
149
+ steps: [
150
+ {
151
+ keyword: 'Given ',
152
+ name: 'the scenario was assigned to this runner',
153
+ result: {
154
+ status: 'failed',
155
+ error_message: `Runner "${laneName}" did not complete: ${reason}`,
156
+ duration: 0
157
+ }
158
+ }
159
+ ]
160
+ }))
161
+ }
162
+ ]);
163
+ }
164
+
113
165
  // ---------------------------------------------------------------------------
114
166
  // Single built-in runner
115
167
  // ---------------------------------------------------------------------------
116
168
 
169
+ function startSsPoller(ssDir, onScreenshot) {
170
+ const seenFiles = new Set();
171
+ return setInterval(() => {
172
+ try {
173
+ const files = fs
174
+ .readdirSync(ssDir)
175
+ .filter((f) => f.endsWith('.ss.json'))
176
+ .sort();
177
+ for (const f of files) {
178
+ if (seenFiles.has(f)) continue;
179
+ seenFiles.add(f);
180
+ const filePath = path.join(ssDir, f);
181
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
182
+ onScreenshot(data);
183
+ try {
184
+ fs.unlinkSync(filePath);
185
+ } catch {}
186
+ }
187
+ } catch {}
188
+ }, 400);
189
+ }
190
+
117
191
  function runBuiltIn(
118
192
  io,
119
193
  socket,
@@ -125,12 +199,16 @@ function runBuiltIn(
125
199
  notifyDiscord,
126
200
  notifySlack
127
201
  ) {
202
+ const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}`);
203
+ fs.mkdirSync(ssDir, { recursive: true });
204
+
128
205
  const env = {
129
206
  ...process.env,
130
207
  TAG: tag,
131
208
  TRIGGER: TRIGGER_TYPE.MANUAL,
132
209
  REPORT_RUNNERS: String(workers),
133
- BROWSER: browser
210
+ BROWSER: browser,
211
+ PLUM_SS_DIR: ssDir
134
212
  };
135
213
  if (workers > 1) env.PARALLEL = String(workers);
136
214
  if (testRunId) env.TEST_RUN_ID = testRunId;
@@ -138,15 +216,47 @@ function runBuiltIn(
138
216
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
139
217
  activeProcs.add(proc);
140
218
 
141
- proc.stdout.on('data', (d) => socket.emit('log', d.toString()));
142
- proc.stderr.on('data', (d) => socket.emit('log', `[ERROR] ${d.toString()}`));
219
+ const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
220
+ socket.emit('step-screenshot', { stepName, data });
221
+ });
143
222
 
144
- proc.on('close', (code) => {
223
+ let logBuffer = '';
224
+ proc.stdout.on('data', (d) => {
225
+ const text = d.toString();
226
+ logBuffer += text;
227
+ socket.emit('log', text);
228
+ });
229
+ proc.stderr.on('data', (d) => {
230
+ const text = `[ERROR] ${d.toString()}`;
231
+ logBuffer += text;
232
+ socket.emit('log', text);
233
+ });
234
+
235
+ proc.on('close', async (code) => {
236
+ clearInterval(ssPoller);
237
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
145
238
  activeProcs.delete(proc);
146
239
  socket.emit('log', `\nTest finished with code ${code}`);
147
240
  socket.emit('done', code);
148
241
  io.emit('report-ready');
149
242
 
243
+ // Attach accumulated logs to the report generate-report.js just saved
244
+ try {
245
+ const latest = await prisma.report.findFirst({
246
+ where: { triggerType: TRIGGER_TYPE.MANUAL },
247
+ orderBy: { createdAt: 'desc' },
248
+ select: { id: true }
249
+ });
250
+ if (latest) {
251
+ await prisma.report.update({
252
+ where: { id: latest.id },
253
+ data: { logs: logBuffer || null }
254
+ });
255
+ }
256
+ } catch (e) {
257
+ console.error('[socket] Failed to save run logs:', e.message);
258
+ }
259
+
150
260
  if (notifyDiscord || notifySlack) {
151
261
  prisma.report
152
262
  .findFirst({
@@ -224,6 +334,8 @@ async function runDistributed(
224
334
 
225
335
  const total = activeRunnerIds.length;
226
336
  const collectedReports = new Array(total).fill(null);
337
+ const laneLogs = {};
338
+ for (const l of laneInfos) laneLogs[l.id] = '';
227
339
  let doneCount = 0;
228
340
  let overallCode = 0;
229
341
 
@@ -244,7 +356,8 @@ async function runDistributed(
244
356
  tag,
245
357
  triggerType: TRIGGER_TYPE.MANUAL,
246
358
  browser,
247
- testRunId: testRunId ?? null
359
+ testRunId: testRunId ?? null,
360
+ laneLogs
248
361
  })
249
362
  .then((saved) => {
250
363
  // Result is authoritative from the merged report, not the exit code —
@@ -278,40 +391,73 @@ async function runDistributed(
278
391
  for (let i = 0; i < activeRunnerIds.length; i++) {
279
392
  const lane = laneInfos[i];
280
393
  const chunkTag = buildTagExpression(chunks[i]);
394
+ const chunkIds = chunks[i];
281
395
 
282
396
  if (lane.id === BUILT_IN_RUNNER_ID) {
397
+ const laneId = lane.id;
398
+ const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}-${i}`);
399
+ fs.mkdirSync(ssDir, { recursive: true });
400
+
283
401
  const env = {
284
402
  ...process.env,
285
403
  TAG: chunkTag,
286
404
  TRIGGER: TRIGGER_REMOTE,
287
405
  BROWSER: browser,
288
406
  REPORT_RUNNERS: String(workers),
289
- PLUM_MODE: 'node'
407
+ PLUM_MODE: 'node',
408
+ PLUM_SS_DIR: ssDir
290
409
  };
291
410
  if (workers > 1) env.PARALLEL = String(workers);
292
411
 
293
412
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
294
413
  activeProcs.add(proc);
295
414
 
296
- proc.stdout.on('data', (d) =>
297
- socket.emit('runner-lane-log', { id: lane.id, log: d.toString() })
298
- );
299
- proc.stderr.on('data', (d) =>
300
- socket.emit('runner-lane-log', { id: lane.id, log: `[ERROR] ${d.toString()}` })
301
- );
415
+ const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
416
+ socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
417
+ });
418
+
419
+ proc.stdout.on('data', (d) => {
420
+ const text = d.toString();
421
+ laneLogs[laneId] += text;
422
+ socket.emit('runner-lane-log', { id: laneId, log: text });
423
+ });
424
+ proc.stderr.on('data', (d) => {
425
+ const text = `[ERROR] ${d.toString()}`;
426
+ laneLogs[laneId] += text;
427
+ socket.emit('runner-lane-log', { id: laneId, log: text });
428
+ });
302
429
 
303
430
  const idx = i;
304
431
  proc.on('close', (code) => {
432
+ clearInterval(ssPoller);
433
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
305
434
  activeProcs.delete(proc);
306
- onLaneDone(idx, lane.id, code, readCucumberReportFile());
435
+ const content =
436
+ readCucumberReportFile() ??
437
+ makeSyntheticFailReport(lane.name, chunkIds, 'process exited with error');
438
+ onLaneDone(idx, laneId, code, content);
307
439
  });
308
440
  } else {
309
441
  const idx = i;
442
+ const laneId = lane.id;
310
443
  runnerService.dispatchAndPoll(
311
- lane.id,
444
+ laneId,
312
445
  { tags: chunkTag, browser, workers },
313
- (log) => socket.emit('runner-lane-log', { id: lane.id, log }),
314
- (code, content) => onLaneDone(idx, lane.id, code, content)
446
+ (log) => {
447
+ laneLogs[laneId] += log;
448
+ socket.emit('runner-lane-log', { id: laneId, log });
449
+ },
450
+ (code, content) =>
451
+ onLaneDone(
452
+ idx,
453
+ laneId,
454
+ code,
455
+ content ??
456
+ makeSyntheticFailReport(lane.name, chunkIds, 'could not fetch report from runner')
457
+ ),
458
+ ({ stepName, data }) => {
459
+ socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
460
+ }
315
461
  );
316
462
  }
317
463
  }