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.
- package/CLAUDE.md +1 -1
- package/backend/_scaffold/utils/browser.ts +47 -2
- package/backend/_scaffold/utils/hooks.ts +25 -2
- package/backend/config/scripts/run-tests.js +35 -13
- package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
- package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
- package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
- package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
- package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
- package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
- package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
- package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
- package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
- package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
- package/backend/prisma/migrations/20260707032554_add_perf_indexes/migration.sql +44 -0
- package/backend/prisma/schema.prisma +20 -0
- package/backend/routes/node.routes.js +42 -4
- package/backend/routes/reports.routes.js +4 -2
- package/backend/services/reportService.js +58 -20
- package/backend/services/runnerService.js +11 -1
- package/backend/websockets/socketHandler.js +162 -16
- package/bin/plum.js +96 -68
- package/frontend/package.json +1 -1
- package/frontend/src/lib/api/reports.js +16 -5
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
- package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
- package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
- package/frontend/src/lib/components/ui/Badge.svelte +1 -1
- package/frontend/src/lib/components/ui/Button.svelte +1 -1
- package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
- package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
- package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
- package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
- package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
- package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
- package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
- package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
- package/frontend/src/lib/constants.js +14 -0
- package/frontend/src/lib/stores/runner.js +5 -3
- package/frontend/src/lib/styles/tokens.css +26 -0
- package/frontend/src/lib/utils/format.js +74 -0
- package/frontend/src/routes/+page.svelte +3 -3
- package/frontend/src/routes/login/+page.svelte +55 -40
- package/frontend/src/routes/reports/+page.svelte +35 -27
- package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
- package/frontend/src/routes/reports/live/+page.svelte +281 -283
- package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
- package/frontend/src/routes/settings/+page.svelte +6 -6
- package/frontend/src/routes/setup/+page.svelte +2 -1
- package/frontend/src/routes/test-repository/+page.svelte +5 -5
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +33 -152
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
- package/frontend/vite.config.js +7 -1
- 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(
|
|
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(
|
|
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
|
|
25
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
142
|
-
|
|
219
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
220
|
+
socket.emit('step-screenshot', { stepName, data });
|
|
221
|
+
});
|
|
143
222
|
|
|
144
|
-
|
|
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
|
-
|
|
297
|
-
socket.emit('runner-lane-
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
+
laneId,
|
|
312
445
|
{ tags: chunkTag, browser, workers },
|
|
313
|
-
(log) =>
|
|
314
|
-
|
|
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
|
}
|