plum-e2e 2.6.0 → 2.6.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/backend/app.js +4 -1
- package/backend/lib/retryRunner.js +61 -0
- package/backend/prisma/migrations/20260714070000_add_project_max_retries/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- package/backend/routes/node.routes.js +20 -0
- package/backend/routes/runners.routes.js +19 -10
- package/backend/routes/settings.routes.js +2 -2
- package/backend/routes/trigger.routes.js +103 -55
- package/backend/server.js +21 -2
- package/backend/services/cronService.js +185 -70
- package/backend/services/reportService.js +74 -6
- package/backend/services/runnerService.js +25 -0
- package/backend/services/settingsService.js +3 -2
- package/backend/websockets/socketHandler.js +199 -90
- package/frontend/src/lib/api/runners.js +10 -0
- package/frontend/src/lib/api/settings.js +3 -3
- package/frontend/src/lib/constants.js +1 -0
- package/frontend/src/routes/reports/[id]/+page.svelte +8 -0
- package/frontend/src/routes/settings/+page.svelte +85 -3
- package/package.json +1 -1
|
@@ -23,11 +23,13 @@ const path = require('path');
|
|
|
23
23
|
const prisma = require('./prisma');
|
|
24
24
|
const runnerService = require('./runnerService');
|
|
25
25
|
const reportService = require('./reportService');
|
|
26
|
+
const settingsService = require('./settingsService');
|
|
26
27
|
const notificationService = require('./notificationService');
|
|
27
28
|
const { startSsPoller } = require('../lib/screenshotPoller');
|
|
28
29
|
const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
29
30
|
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
30
31
|
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
32
|
+
const { runWithRetries } = require('../lib/retryRunner');
|
|
31
33
|
|
|
32
34
|
const scheduledJobs = {};
|
|
33
35
|
let _io = null;
|
|
@@ -64,41 +66,70 @@ async function resolveLaneInfos(runnerIds) {
|
|
|
64
66
|
// Run paths
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
66
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Spawns one `npm run test` attempt for a single-built-in-runner cron task.
|
|
71
|
+
* When `suppressSave` is set, PLUM_MODE=node forces generate-report.js to skip
|
|
72
|
+
* its own DB save — used by the retry path, which persists exactly one merged
|
|
73
|
+
* report itself once every attempt is done.
|
|
74
|
+
*/
|
|
75
|
+
function runSingleBuiltInAttempt({ taskName, currentTag, workers, browser, suppressSave, onLog }) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const ssDir = path.join(os.tmpdir(), `plum-cron-ss-${Date.now()}`);
|
|
78
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
const env = {
|
|
81
|
+
...process.env,
|
|
82
|
+
TAG: currentTag,
|
|
83
|
+
TRIGGER: taskName,
|
|
84
|
+
BROWSER: browser,
|
|
85
|
+
PLUM_SS_DIR: ssDir
|
|
86
|
+
};
|
|
87
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
88
|
+
if (suppressSave) env.PLUM_MODE = 'node';
|
|
89
|
+
|
|
90
|
+
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
91
|
+
|
|
92
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
93
|
+
if (_io) _io.emit('bg-run-screenshot', { runId: taskName, stepName, data });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
task.stdout.on('data', (d) => {
|
|
97
|
+
process.stdout.write(d);
|
|
98
|
+
onLog(d.toString());
|
|
99
|
+
});
|
|
100
|
+
task.stderr.on('data', (d) => {
|
|
101
|
+
process.stderr.write(d);
|
|
102
|
+
onLog(`[ERROR] ${d.toString()}`);
|
|
103
|
+
});
|
|
104
|
+
task.on('close', (code) => {
|
|
105
|
+
clearInterval(ssPoller);
|
|
106
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
107
|
+
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
/**
|
|
68
113
|
* Single built-in runner — spawns tests locally.
|
|
69
114
|
* TRIGGER is set to taskName so generate-report.js can persist it correctly.
|
|
70
115
|
*/
|
|
71
|
-
function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack }) {
|
|
72
|
-
const ssDir = path.join(os.tmpdir(), `plum-cron-ss-${Date.now()}`);
|
|
73
|
-
fs.mkdirSync(ssDir, { recursive: true });
|
|
116
|
+
async function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack }) {
|
|
74
117
|
const startedAt = Date.now();
|
|
118
|
+
const { maxRetries } = await settingsService.getProject();
|
|
75
119
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
TAG: tags,
|
|
79
|
-
TRIGGER: taskName,
|
|
80
|
-
BROWSER: browser,
|
|
81
|
-
PLUM_SS_DIR: ssDir
|
|
120
|
+
const onLog = (text) => {
|
|
121
|
+
if (_io) _io.emit('bg-run-log', { runId: taskName, log: text });
|
|
82
122
|
};
|
|
83
|
-
if (workers > 1) env.PARALLEL = String(workers);
|
|
84
|
-
|
|
85
|
-
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
86
|
-
|
|
87
|
-
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
88
|
-
if (_io) _io.emit('bg-run-screenshot', { runId: taskName, stepName, data });
|
|
89
|
-
});
|
|
90
123
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
clearInterval(ssPoller);
|
|
101
|
-
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
124
|
+
if (maxRetries === 0) {
|
|
125
|
+
const { code } = await runSingleBuiltInAttempt({
|
|
126
|
+
taskName,
|
|
127
|
+
currentTag: tags,
|
|
128
|
+
workers,
|
|
129
|
+
browser,
|
|
130
|
+
suppressSave: false,
|
|
131
|
+
onLog
|
|
132
|
+
});
|
|
102
133
|
console.log(`Task "${taskName}" finished with code ${code}`);
|
|
103
134
|
|
|
104
135
|
prisma.report
|
|
@@ -133,7 +164,57 @@ function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, not
|
|
|
133
164
|
console.error(`[cron] Notification failed: ${e.message}`);
|
|
134
165
|
if (_io) _io.emit('bg-run-done', { runId: taskName, code, reportId: null });
|
|
135
166
|
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { code, rawJson, attempts } = await runWithRetries({
|
|
171
|
+
maxRetries,
|
|
172
|
+
spawnAttempt: async (tagOverride) => {
|
|
173
|
+
const { code, raw } = await runSingleBuiltInAttempt({
|
|
174
|
+
taskName,
|
|
175
|
+
currentTag: tagOverride ?? tags,
|
|
176
|
+
workers,
|
|
177
|
+
browser,
|
|
178
|
+
suppressSave: true,
|
|
179
|
+
onLog
|
|
180
|
+
});
|
|
181
|
+
return { code, rawJson: raw ? JSON.parse(raw) : [] };
|
|
182
|
+
},
|
|
183
|
+
onLog
|
|
136
184
|
});
|
|
185
|
+
|
|
186
|
+
console.log(`Task "${taskName}" finished with code ${code}`);
|
|
187
|
+
|
|
188
|
+
let report = null;
|
|
189
|
+
try {
|
|
190
|
+
report = await reportService.saveReport({
|
|
191
|
+
rawCucumberJson: rawJson,
|
|
192
|
+
tags,
|
|
193
|
+
triggerType: taskName,
|
|
194
|
+
browser,
|
|
195
|
+
duration: Date.now() - startedAt,
|
|
196
|
+
attempts
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error(`[cron] Failed to save report: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (_io) _io.emit('bg-run-done', { runId: taskName, code, reportId: report?.id ?? null });
|
|
203
|
+
|
|
204
|
+
if (report && (notifyDiscord || notifySlack)) {
|
|
205
|
+
notificationService
|
|
206
|
+
.send({
|
|
207
|
+
jobName: taskName,
|
|
208
|
+
status: report.status,
|
|
209
|
+
content: report.content,
|
|
210
|
+
browser,
|
|
211
|
+
tags,
|
|
212
|
+
reportId: report.id,
|
|
213
|
+
notifyDiscord,
|
|
214
|
+
notifySlack
|
|
215
|
+
})
|
|
216
|
+
.catch((e) => console.error(`[cron] Notification failed: ${e.message}`));
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
|
|
139
220
|
/**
|
|
@@ -150,6 +231,7 @@ async function runDistributed({
|
|
|
150
231
|
notifySlack
|
|
151
232
|
}) {
|
|
152
233
|
const dispatchStartedAt = Date.now();
|
|
234
|
+
const { maxRetries } = await settingsService.getProject();
|
|
153
235
|
const allIds = getTestIdsForTag(tags);
|
|
154
236
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
155
237
|
|
|
@@ -173,12 +255,14 @@ async function runDistributed({
|
|
|
173
255
|
}
|
|
174
256
|
|
|
175
257
|
const collectedReports = new Array(activeRunnerIds.length).fill(null);
|
|
258
|
+
const laneAttempts = new Array(activeRunnerIds.length).fill(null);
|
|
176
259
|
let doneCount = 0;
|
|
177
260
|
let overallCode = 0;
|
|
178
261
|
|
|
179
|
-
function onLaneDone(idx, laneId, code, reportContent) {
|
|
262
|
+
function onLaneDone(idx, laneId, code, reportContent, attempts = null) {
|
|
180
263
|
if (code !== 0) overallCode = code;
|
|
181
264
|
collectedReports[idx] = reportContent;
|
|
265
|
+
laneAttempts[idx] = attempts;
|
|
182
266
|
doneCount++;
|
|
183
267
|
if (_io) {
|
|
184
268
|
_io.emit('bg-run-lane-status', {
|
|
@@ -199,7 +283,8 @@ async function runDistributed({
|
|
|
199
283
|
tag: tags,
|
|
200
284
|
triggerType: taskName,
|
|
201
285
|
browser,
|
|
202
|
-
duration: Date.now() - dispatchStartedAt
|
|
286
|
+
duration: Date.now() - dispatchStartedAt,
|
|
287
|
+
attemptsByLane: laneAttempts
|
|
203
288
|
})
|
|
204
289
|
.then((saved) => {
|
|
205
290
|
if (_io) {
|
|
@@ -235,53 +320,83 @@ async function runDistributed({
|
|
|
235
320
|
const chunkTag = buildTagExpression(chunks[i]);
|
|
236
321
|
|
|
237
322
|
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const env = {
|
|
242
|
-
...process.env,
|
|
243
|
-
TAG: chunkTag,
|
|
244
|
-
TRIGGER: TRIGGER_REMOTE, // node-mode: file naming only, not persisted to DB
|
|
245
|
-
BROWSER: browser,
|
|
246
|
-
PLUM_MODE: 'node',
|
|
247
|
-
PLUM_SS_DIR: ssDir
|
|
323
|
+
const idx = i;
|
|
324
|
+
const onLog = (text) => {
|
|
325
|
+
if (_io) _io.emit('bg-run-lane-log', { runId: taskName, laneId, log: text });
|
|
248
326
|
};
|
|
249
|
-
if (workers > 1) env.PARALLEL = String(workers);
|
|
250
327
|
|
|
251
|
-
const
|
|
328
|
+
const spawnBuiltInLaneAttempt = (currentTag) =>
|
|
329
|
+
new Promise((resolve) => {
|
|
330
|
+
const ssDir = path.join(os.tmpdir(), `plum-cron-ss-${Date.now()}-${idx}`);
|
|
331
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
332
|
+
|
|
333
|
+
const env = {
|
|
334
|
+
...process.env,
|
|
335
|
+
TAG: currentTag,
|
|
336
|
+
TRIGGER: TRIGGER_REMOTE, // node-mode: file naming only, not persisted to DB
|
|
337
|
+
BROWSER: browser,
|
|
338
|
+
PLUM_MODE: 'node',
|
|
339
|
+
PLUM_SS_DIR: ssDir
|
|
340
|
+
};
|
|
341
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
342
|
+
|
|
343
|
+
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
344
|
+
|
|
345
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
346
|
+
if (_io)
|
|
347
|
+
_io.emit('bg-run-lane-screenshot', { runId: taskName, laneId, stepName, data });
|
|
348
|
+
});
|
|
252
349
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
350
|
+
task.stdout.on('data', (d) => {
|
|
351
|
+
process.stdout.write(d);
|
|
352
|
+
onLog(d.toString());
|
|
353
|
+
});
|
|
354
|
+
task.stderr.on('data', (d) => {
|
|
355
|
+
process.stderr.write(d);
|
|
356
|
+
onLog(`[ERROR] ${d.toString()}`);
|
|
357
|
+
});
|
|
358
|
+
task.on('close', (code) => {
|
|
359
|
+
clearInterval(ssPoller);
|
|
360
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
361
|
+
const raw = readCucumberReportFile() ?? '[]';
|
|
362
|
+
resolve({ code, rawJson: JSON.parse(raw) });
|
|
363
|
+
});
|
|
364
|
+
});
|
|
256
365
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (_io) _io.emit('bg-run-lane-log', { runId: taskName, laneId, log: text });
|
|
265
|
-
});
|
|
266
|
-
const idx = i;
|
|
267
|
-
task.on('close', (code) => {
|
|
268
|
-
clearInterval(ssPoller);
|
|
269
|
-
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
270
|
-
onLaneDone(idx, laneId, code, readCucumberReportFile());
|
|
271
|
-
});
|
|
366
|
+
runWithRetries({
|
|
367
|
+
maxRetries,
|
|
368
|
+
spawnAttempt: (t) => spawnBuiltInLaneAttempt(t ?? chunkTag),
|
|
369
|
+
onLog
|
|
370
|
+
}).then(({ code, rawJson, attempts }) =>
|
|
371
|
+
onLaneDone(idx, laneId, code, JSON.stringify(rawJson), attempts)
|
|
372
|
+
);
|
|
272
373
|
} else {
|
|
273
374
|
const idx = i;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
375
|
+
const onLog = (log) => {
|
|
376
|
+
process.stdout.write(log);
|
|
377
|
+
if (_io) _io.emit('bg-run-lane-log', { runId: taskName, laneId, log });
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const spawnRemoteLaneAttempt = (currentTag) =>
|
|
381
|
+
new Promise((resolve) => {
|
|
382
|
+
runnerService.dispatchAndPoll(
|
|
383
|
+
lane.id,
|
|
384
|
+
{ tags: currentTag, browser, workers },
|
|
385
|
+
onLog,
|
|
386
|
+
(code, content) => resolve({ code, rawJson: content ? JSON.parse(content) : [] }),
|
|
387
|
+
({ stepName, data }) => {
|
|
388
|
+
if (_io)
|
|
389
|
+
_io.emit('bg-run-lane-screenshot', { runId: taskName, laneId, stepName, data });
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
runWithRetries({
|
|
395
|
+
maxRetries,
|
|
396
|
+
spawnAttempt: (t) => spawnRemoteLaneAttempt(t ?? chunkTag),
|
|
397
|
+
onLog
|
|
398
|
+
}).then(({ code, rawJson, attempts }) =>
|
|
399
|
+
onLaneDone(idx, laneId, code, JSON.stringify(rawJson), attempts)
|
|
285
400
|
);
|
|
286
401
|
}
|
|
287
402
|
}
|
|
@@ -139,6 +139,64 @@ function featureMergeKey(feature) {
|
|
|
139
139
|
return uri || feature.id || feature.name;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// Mirrors frontend's isTestCaseTag (frontend/src/lib/utils/format.js) — keep in
|
|
143
|
+
// sync so retry-attempt counts line up with how the report page groups scenarios.
|
|
144
|
+
function isTestCaseTag(tag) {
|
|
145
|
+
return /^@test[\w-]*/i.test(tag) || /^@tc-?\d+/i.test(tag);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scenarioIdTag(scenario) {
|
|
149
|
+
return (scenario.tags ?? []).map((t) => t.name).find(isTestCaseTag) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scenarioFailed(scenario) {
|
|
153
|
+
return (scenario.steps ?? []).some((s) => s.result?.status === 'failed');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Test-ID tags (see scenarioIdTag) of every failed scenario in a raw Cucumber
|
|
158
|
+
* JSON payload, deduped. Used to scope the next retry attempt to just the
|
|
159
|
+
* scenarios that need re-running.
|
|
160
|
+
*/
|
|
161
|
+
function getFailedIdTags(rawJson) {
|
|
162
|
+
const tags = new Set();
|
|
163
|
+
for (const feature of rawJson) {
|
|
164
|
+
for (const scenario of feature.elements ?? []) {
|
|
165
|
+
if (scenarioFailed(scenario)) {
|
|
166
|
+
const idTag = scenarioIdTag(scenario);
|
|
167
|
+
if (idTag) tags.add(idTag);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return [...tags];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Folds one retry attempt's raw Cucumber JSON into the accumulated result,
|
|
176
|
+
* replacing each retried scenario's prior-round entry with this round's
|
|
177
|
+
* outcome (matched by Cucumber's own stable scenario id). Mutates
|
|
178
|
+
* `attemptsMap` in place, recording the highest round number each scenario
|
|
179
|
+
* appeared in — that number *is* its total attempt count, since a scenario
|
|
180
|
+
* only reappears in a later round if it failed every round before it.
|
|
181
|
+
*/
|
|
182
|
+
function mergeRawAttempt(accumulated, attemptRawJson, round, attemptsMap) {
|
|
183
|
+
for (const feature of attemptRawJson) {
|
|
184
|
+
const key = featureMergeKey(feature);
|
|
185
|
+
let accFeature = accumulated.find((f) => featureMergeKey(f) === key);
|
|
186
|
+
if (!accFeature) {
|
|
187
|
+
accFeature = { ...feature, elements: [] };
|
|
188
|
+
accumulated.push(accFeature);
|
|
189
|
+
}
|
|
190
|
+
for (const scenario of feature.elements ?? []) {
|
|
191
|
+
accFeature.elements = accFeature.elements.filter((e) => e.id !== scenario.id);
|
|
192
|
+
accFeature.elements.push(scenario);
|
|
193
|
+
const idTag = scenarioIdTag(scenario);
|
|
194
|
+
if (idTag) attemptsMap[idTag] = round;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return accumulated;
|
|
198
|
+
}
|
|
199
|
+
|
|
142
200
|
function deleteScreenshotFiles(content) {
|
|
143
201
|
for (const file of collectScreenshotFiles(content)) {
|
|
144
202
|
const p = path.join(SCREENSHOTS_DIR, file);
|
|
@@ -156,7 +214,7 @@ function deleteScreenshotFiles(content) {
|
|
|
156
214
|
*
|
|
157
215
|
* Returns { features, status } where status is 'PASS' | 'FAIL'.
|
|
158
216
|
*/
|
|
159
|
-
function processCucumberJson(raw) {
|
|
217
|
+
function processCucumberJson(raw, attempts = {}) {
|
|
160
218
|
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
161
219
|
|
|
162
220
|
const features = raw.map((feature) => {
|
|
@@ -215,6 +273,7 @@ function processCucumberJson(raw) {
|
|
|
215
273
|
tags: (scenario.tags ?? []).map((t) => t.name),
|
|
216
274
|
status: worstStatus,
|
|
217
275
|
duration: steps.reduce((s, st) => s + st.duration, 0),
|
|
276
|
+
attempts: attempts[scenarioIdTag(scenario)] ?? 1,
|
|
218
277
|
steps
|
|
219
278
|
};
|
|
220
279
|
});
|
|
@@ -329,10 +388,11 @@ const saveReport = async ({
|
|
|
329
388
|
testRunId,
|
|
330
389
|
forceFail = false,
|
|
331
390
|
logs = null,
|
|
332
|
-
duration = null
|
|
391
|
+
duration = null,
|
|
392
|
+
attempts = {}
|
|
333
393
|
}) => {
|
|
334
394
|
const normTrigger = normaliseTrigger(triggerType);
|
|
335
|
-
const { features, status: derivedStatus } = processCucumberJson(rawCucumberJson);
|
|
395
|
+
const { features, status: derivedStatus } = processCucumberJson(rawCucumberJson, attempts);
|
|
336
396
|
const status = forceFail ? 'FAIL' : derivedStatus;
|
|
337
397
|
const cronJobId = await resolveCronJobId(normTrigger);
|
|
338
398
|
|
|
@@ -379,7 +439,8 @@ const saveCombinedReport = async ({
|
|
|
379
439
|
browser,
|
|
380
440
|
testRunId,
|
|
381
441
|
laneLogs = null,
|
|
382
|
-
duration = null
|
|
442
|
+
duration = null,
|
|
443
|
+
attemptsByLane = null
|
|
383
444
|
}) => {
|
|
384
445
|
const featureMap = new Map();
|
|
385
446
|
for (const content of reports) {
|
|
@@ -413,6 +474,10 @@ const saveCombinedReport = async ({
|
|
|
413
474
|
if (parts.length > 0) combinedLogs = parts.join('\n\n');
|
|
414
475
|
}
|
|
415
476
|
|
|
477
|
+
// Chunked lanes run disjoint sets of tests, so their attempt maps never
|
|
478
|
+
// collide — a plain merge is safe.
|
|
479
|
+
const attempts = attemptsByLane ? Object.assign({}, ...attemptsByLane.filter(Boolean)) : {};
|
|
480
|
+
|
|
416
481
|
return saveReport({
|
|
417
482
|
rawCucumberJson: combined,
|
|
418
483
|
tags: tag,
|
|
@@ -424,7 +489,8 @@ const saveCombinedReport = async ({
|
|
|
424
489
|
testRunId: testRunId ?? null,
|
|
425
490
|
forceFail: reports.some((r) => r === null),
|
|
426
491
|
logs: combinedLogs,
|
|
427
|
-
duration
|
|
492
|
+
duration,
|
|
493
|
+
attempts
|
|
428
494
|
});
|
|
429
495
|
};
|
|
430
496
|
|
|
@@ -475,5 +541,7 @@ module.exports = {
|
|
|
475
541
|
saveCombinedReport,
|
|
476
542
|
syncAutomatedFromFeatures,
|
|
477
543
|
deleteReport,
|
|
478
|
-
deleteReports
|
|
544
|
+
deleteReports,
|
|
545
|
+
getFailedIdTags,
|
|
546
|
+
mergeRawAttempt
|
|
479
547
|
};
|
|
@@ -85,6 +85,29 @@ async function ping(id) {
|
|
|
85
85
|
return probe({ url: runner.url, token: runner.token });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Remote control
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async function callControlEndpoint(id, endpoint, timeoutMs) {
|
|
93
|
+
const runner = await getById(id);
|
|
94
|
+
if (!runner) return { ok: false, error: 'Runner not found' };
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`${runner.url}/api/${endpoint}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { Authorization: `Bearer ${runner.token}` },
|
|
99
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
|
102
|
+
return { ok: true };
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { ok: false, error: e.message };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stop = (id) => callControlEndpoint(id, 'shutdown', 5000);
|
|
109
|
+
const restart = (id) => callControlEndpoint(id, 'restart', 5000);
|
|
110
|
+
|
|
88
111
|
// ---------------------------------------------------------------------------
|
|
89
112
|
// Remote execution
|
|
90
113
|
// ---------------------------------------------------------------------------
|
|
@@ -235,5 +258,7 @@ module.exports = {
|
|
|
235
258
|
getById,
|
|
236
259
|
probe,
|
|
237
260
|
ping,
|
|
261
|
+
stop,
|
|
262
|
+
restart,
|
|
238
263
|
dispatchAndPoll
|
|
239
264
|
};
|
|
@@ -26,11 +26,12 @@ const getProject = async () => {
|
|
|
26
26
|
return project;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const updateProject = async ({ name, logoUrl, timezone }) => {
|
|
29
|
+
const updateProject = async ({ name, logoUrl, timezone, maxRetries }) => {
|
|
30
30
|
const data = {
|
|
31
31
|
name: name ?? '',
|
|
32
32
|
logoUrl: logoUrl ?? '',
|
|
33
|
-
...(timezone !== undefined && { timezone })
|
|
33
|
+
...(timezone !== undefined && { timezone }),
|
|
34
|
+
...(maxRetries !== undefined && { maxRetries: Number(maxRetries) || 0 })
|
|
34
35
|
};
|
|
35
36
|
const project = await prisma.project.upsert({
|
|
36
37
|
where: { id: 1 },
|