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
|
@@ -21,8 +21,10 @@ const os = require('os');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const runnerService = require('../services/runnerService');
|
|
23
23
|
const reportService = require('../services/reportService');
|
|
24
|
+
const settingsService = require('../services/settingsService');
|
|
24
25
|
const notificationService = require('../services/notificationService');
|
|
25
26
|
const { startSsPoller } = require('../lib/screenshotPoller');
|
|
27
|
+
const { runWithRetries } = require('../lib/retryRunner');
|
|
26
28
|
const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
27
29
|
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
28
30
|
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
@@ -167,7 +169,59 @@ function makeSyntheticFailReport(laneName, testIds, reason) {
|
|
|
167
169
|
// Single built-in runner
|
|
168
170
|
// ---------------------------------------------------------------------------
|
|
169
171
|
|
|
170
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Spawns one `npm run test` attempt for the built-in (local) runner and
|
|
174
|
+
* resolves once it exits. When `suppressSave` is set, PLUM_MODE=node is
|
|
175
|
+
* forced so generate-report.js skips its own DB save — used by the retry
|
|
176
|
+
* path, which persists exactly one merged report itself once every attempt
|
|
177
|
+
* is done, instead of one row per attempt.
|
|
178
|
+
*/
|
|
179
|
+
function runBuiltInAttempt({
|
|
180
|
+
activeProcs,
|
|
181
|
+
socket,
|
|
182
|
+
currentTag,
|
|
183
|
+
workers,
|
|
184
|
+
browser,
|
|
185
|
+
testRunId,
|
|
186
|
+
suppressSave,
|
|
187
|
+
onLog
|
|
188
|
+
}) {
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}`);
|
|
191
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
192
|
+
|
|
193
|
+
const env = {
|
|
194
|
+
...process.env,
|
|
195
|
+
TAG: currentTag,
|
|
196
|
+
TRIGGER: TRIGGER_TYPE.MANUAL,
|
|
197
|
+
REPORT_RUNNERS: String(workers),
|
|
198
|
+
BROWSER: browser,
|
|
199
|
+
PLUM_SS_DIR: ssDir
|
|
200
|
+
};
|
|
201
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
202
|
+
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
203
|
+
if (suppressSave) env.PLUM_MODE = 'node';
|
|
204
|
+
|
|
205
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
206
|
+
activeProcs.add(proc);
|
|
207
|
+
|
|
208
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
209
|
+
socket.emit('step-screenshot', { stepName, data });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
proc.stdout.on('data', (d) => onLog(d.toString()));
|
|
213
|
+
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
214
|
+
|
|
215
|
+
proc.on('close', (code) => {
|
|
216
|
+
clearInterval(ssPoller);
|
|
217
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
218
|
+
activeProcs.delete(proc);
|
|
219
|
+
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function runBuiltIn(
|
|
171
225
|
io,
|
|
172
226
|
socket,
|
|
173
227
|
activeProcs,
|
|
@@ -178,44 +232,26 @@ function runBuiltIn(
|
|
|
178
232
|
notifyDiscord,
|
|
179
233
|
notifySlack
|
|
180
234
|
) {
|
|
181
|
-
const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}`);
|
|
182
|
-
fs.mkdirSync(ssDir, { recursive: true });
|
|
183
235
|
const startedAt = Date.now();
|
|
184
|
-
|
|
185
|
-
const env = {
|
|
186
|
-
...process.env,
|
|
187
|
-
TAG: tag,
|
|
188
|
-
TRIGGER: TRIGGER_TYPE.MANUAL,
|
|
189
|
-
REPORT_RUNNERS: String(workers),
|
|
190
|
-
BROWSER: browser,
|
|
191
|
-
PLUM_SS_DIR: ssDir
|
|
192
|
-
};
|
|
193
|
-
if (workers > 1) env.PARALLEL = String(workers);
|
|
194
|
-
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
195
|
-
|
|
196
|
-
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
197
|
-
activeProcs.add(proc);
|
|
198
|
-
|
|
199
|
-
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
200
|
-
socket.emit('step-screenshot', { stepName, data });
|
|
201
|
-
});
|
|
236
|
+
const { maxRetries } = await settingsService.getProject();
|
|
202
237
|
|
|
203
238
|
let logBuffer = '';
|
|
204
|
-
|
|
205
|
-
const text = d.toString();
|
|
206
|
-
logBuffer += text;
|
|
207
|
-
socket.emit('log', text);
|
|
208
|
-
});
|
|
209
|
-
proc.stderr.on('data', (d) => {
|
|
210
|
-
const text = `[ERROR] ${d.toString()}`;
|
|
239
|
+
const onLog = (text) => {
|
|
211
240
|
logBuffer += text;
|
|
212
241
|
socket.emit('log', text);
|
|
213
|
-
}
|
|
242
|
+
};
|
|
214
243
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
if (maxRetries === 0) {
|
|
245
|
+
const { code } = await runBuiltInAttempt({
|
|
246
|
+
activeProcs,
|
|
247
|
+
socket,
|
|
248
|
+
currentTag: tag,
|
|
249
|
+
workers,
|
|
250
|
+
browser,
|
|
251
|
+
testRunId,
|
|
252
|
+
suppressSave: false,
|
|
253
|
+
onLog
|
|
254
|
+
});
|
|
219
255
|
socket.emit('log', `\nTest finished with code ${code}`);
|
|
220
256
|
socket.emit('done', code);
|
|
221
257
|
io.emit('report-ready');
|
|
@@ -259,7 +295,57 @@ function runBuiltIn(
|
|
|
259
295
|
})
|
|
260
296
|
.catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
|
|
261
297
|
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { code, rawJson, attempts } = await runWithRetries({
|
|
302
|
+
maxRetries,
|
|
303
|
+
spawnAttempt: async (tagOverride) => {
|
|
304
|
+
const { code, raw } = await runBuiltInAttempt({
|
|
305
|
+
activeProcs,
|
|
306
|
+
socket,
|
|
307
|
+
currentTag: tagOverride ?? tag,
|
|
308
|
+
workers,
|
|
309
|
+
browser,
|
|
310
|
+
testRunId,
|
|
311
|
+
suppressSave: true,
|
|
312
|
+
onLog
|
|
313
|
+
});
|
|
314
|
+
return { code, rawJson: raw ? JSON.parse(raw) : [] };
|
|
315
|
+
},
|
|
316
|
+
onLog
|
|
262
317
|
});
|
|
318
|
+
|
|
319
|
+
socket.emit('log', `\nTest finished with code ${code}`);
|
|
320
|
+
|
|
321
|
+
const report = await reportService.saveReport({
|
|
322
|
+
rawCucumberJson: rawJson,
|
|
323
|
+
tags: tag,
|
|
324
|
+
triggerType: TRIGGER_TYPE.MANUAL,
|
|
325
|
+
browser,
|
|
326
|
+
testRunId: testRunId ?? null,
|
|
327
|
+
logs: logBuffer || null,
|
|
328
|
+
duration: Date.now() - startedAt,
|
|
329
|
+
attempts
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
socket.emit('done', code);
|
|
333
|
+
io.emit('report-ready');
|
|
334
|
+
|
|
335
|
+
if (notifyDiscord || notifySlack) {
|
|
336
|
+
notificationService
|
|
337
|
+
.send({
|
|
338
|
+
jobName: 'Manual Run',
|
|
339
|
+
status: report.status,
|
|
340
|
+
content: report.content,
|
|
341
|
+
browser,
|
|
342
|
+
tags: tag,
|
|
343
|
+
reportId: report.id,
|
|
344
|
+
notifyDiscord,
|
|
345
|
+
notifySlack
|
|
346
|
+
})
|
|
347
|
+
.catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
|
|
348
|
+
}
|
|
263
349
|
}
|
|
264
350
|
|
|
265
351
|
// ---------------------------------------------------------------------------
|
|
@@ -279,6 +365,7 @@ async function runDistributed(
|
|
|
279
365
|
notifySlack
|
|
280
366
|
) {
|
|
281
367
|
const dispatchStartedAt = Date.now();
|
|
368
|
+
const { maxRetries } = await settingsService.getProject();
|
|
282
369
|
const allIds = getTestIdsForTag(tag);
|
|
283
370
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
284
371
|
|
|
@@ -315,14 +402,16 @@ async function runDistributed(
|
|
|
315
402
|
|
|
316
403
|
const total = activeRunnerIds.length;
|
|
317
404
|
const collectedReports = new Array(total).fill(null);
|
|
405
|
+
const laneAttempts = new Array(total).fill(null);
|
|
318
406
|
const laneLogs = {};
|
|
319
407
|
for (const l of laneInfos) laneLogs[l.id] = '';
|
|
320
408
|
let doneCount = 0;
|
|
321
409
|
let overallCode = 0;
|
|
322
410
|
|
|
323
|
-
function onLaneDone(idx, laneId, code, reportContent) {
|
|
411
|
+
function onLaneDone(idx, laneId, code, reportContent, attempts = null) {
|
|
324
412
|
if (code !== 0) overallCode = code;
|
|
325
413
|
collectedReports[idx] = reportContent;
|
|
414
|
+
laneAttempts[idx] = attempts;
|
|
326
415
|
socket.emit('runner-lane-status', { id: laneId, status: code === 0 ? 'done' : 'error' });
|
|
327
416
|
doneCount++;
|
|
328
417
|
|
|
@@ -339,7 +428,8 @@ async function runDistributed(
|
|
|
339
428
|
browser,
|
|
340
429
|
testRunId: testRunId ?? null,
|
|
341
430
|
laneLogs,
|
|
342
|
-
duration: Date.now() - dispatchStartedAt
|
|
431
|
+
duration: Date.now() - dispatchStartedAt,
|
|
432
|
+
attemptsByLane: laneAttempts
|
|
343
433
|
})
|
|
344
434
|
.then((saved) => {
|
|
345
435
|
// Result is authoritative from the merged report, not the exit code —
|
|
@@ -376,70 +466,89 @@ async function runDistributed(
|
|
|
376
466
|
const chunkIds = chunks[i];
|
|
377
467
|
|
|
378
468
|
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
469
|
+
const idx = i;
|
|
379
470
|
const laneId = lane.id;
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const env = {
|
|
384
|
-
...process.env,
|
|
385
|
-
TAG: chunkTag,
|
|
386
|
-
TRIGGER: TRIGGER_REMOTE,
|
|
387
|
-
BROWSER: browser,
|
|
388
|
-
REPORT_RUNNERS: String(workers),
|
|
389
|
-
PLUM_MODE: 'node',
|
|
390
|
-
PLUM_SS_DIR: ssDir
|
|
471
|
+
const onLog = (text) => {
|
|
472
|
+
laneLogs[laneId] += text;
|
|
473
|
+
socket.emit('runner-lane-log', { id: laneId, log: text });
|
|
391
474
|
};
|
|
392
|
-
if (workers > 1) env.PARALLEL = String(workers);
|
|
393
475
|
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
476
|
+
const spawnBuiltInLaneAttempt = (currentTag) =>
|
|
477
|
+
new Promise((resolve) => {
|
|
478
|
+
const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}-${idx}`);
|
|
479
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
480
|
+
|
|
481
|
+
const env = {
|
|
482
|
+
...process.env,
|
|
483
|
+
TAG: currentTag,
|
|
484
|
+
TRIGGER: TRIGGER_REMOTE,
|
|
485
|
+
BROWSER: browser,
|
|
486
|
+
REPORT_RUNNERS: String(workers),
|
|
487
|
+
PLUM_MODE: 'node',
|
|
488
|
+
PLUM_SS_DIR: ssDir
|
|
489
|
+
};
|
|
490
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
491
|
+
|
|
492
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
493
|
+
activeProcs.add(proc);
|
|
494
|
+
|
|
495
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
496
|
+
socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
|
|
497
|
+
});
|
|
400
498
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
499
|
+
proc.stdout.on('data', (d) => onLog(d.toString()));
|
|
500
|
+
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
501
|
+
|
|
502
|
+
proc.on('close', (code) => {
|
|
503
|
+
clearInterval(ssPoller);
|
|
504
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
505
|
+
activeProcs.delete(proc);
|
|
506
|
+
const content =
|
|
507
|
+
readCucumberReportFile() ??
|
|
508
|
+
makeSyntheticFailReport(lane.name, chunkIds, 'process exited with error');
|
|
509
|
+
resolve({ code, rawJson: JSON.parse(content) });
|
|
510
|
+
});
|
|
511
|
+
});
|
|
411
512
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
makeSyntheticFailReport(lane.name, chunkIds, 'process exited with error');
|
|
420
|
-
onLaneDone(idx, laneId, code, content);
|
|
421
|
-
});
|
|
513
|
+
runWithRetries({
|
|
514
|
+
maxRetries,
|
|
515
|
+
spawnAttempt: (t) => spawnBuiltInLaneAttempt(t ?? chunkTag),
|
|
516
|
+
onLog
|
|
517
|
+
}).then(({ code, rawJson, attempts }) =>
|
|
518
|
+
onLaneDone(idx, laneId, code, JSON.stringify(rawJson), attempts)
|
|
519
|
+
);
|
|
422
520
|
} else {
|
|
423
521
|
const idx = i;
|
|
424
522
|
const laneId = lane.id;
|
|
425
|
-
|
|
426
|
-
laneId
|
|
427
|
-
{
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
onLaneDone(
|
|
434
|
-
idx,
|
|
523
|
+
const onLog = (log) => {
|
|
524
|
+
laneLogs[laneId] += log;
|
|
525
|
+
socket.emit('runner-lane-log', { id: laneId, log });
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const spawnRemoteLaneAttempt = (currentTag) =>
|
|
529
|
+
new Promise((resolve) => {
|
|
530
|
+
runnerService.dispatchAndPoll(
|
|
435
531
|
laneId,
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
532
|
+
{ tags: currentTag, browser, workers },
|
|
533
|
+
onLog,
|
|
534
|
+
(code, content) => {
|
|
535
|
+
const raw =
|
|
536
|
+
content ??
|
|
537
|
+
makeSyntheticFailReport(lane.name, chunkIds, 'could not fetch report from runner');
|
|
538
|
+
resolve({ code, rawJson: JSON.parse(raw) });
|
|
539
|
+
},
|
|
540
|
+
({ stepName, data }) => {
|
|
541
|
+
socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
runWithRetries({
|
|
547
|
+
maxRetries,
|
|
548
|
+
spawnAttempt: (t) => spawnRemoteLaneAttempt(t ?? chunkTag),
|
|
549
|
+
onLog
|
|
550
|
+
}).then(({ code, rawJson, attempts }) =>
|
|
551
|
+
onLaneDone(idx, laneId, code, JSON.stringify(rawJson), attempts)
|
|
443
552
|
);
|
|
444
553
|
}
|
|
445
554
|
}
|
|
@@ -51,6 +51,16 @@ export async function pingRunner(id) {
|
|
|
51
51
|
return res.json();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export async function stopRunner(id) {
|
|
55
|
+
const res = await fetch(`${API_BASE}/runners/${id}/stop`, { method: 'POST' });
|
|
56
|
+
return res.json();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function restartRunner(id) {
|
|
60
|
+
const res = await fetch(`${API_BASE}/runners/${id}/restart`, { method: 'POST' });
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
export async function probeRunner(url, token) {
|
|
55
65
|
const res = await fetch(`${API_BASE}/runners/probe`, {
|
|
56
66
|
method: 'POST',
|
|
@@ -24,15 +24,15 @@ function authHeaders() {
|
|
|
24
24
|
|
|
25
25
|
export async function fetchProject() {
|
|
26
26
|
const res = await fetch(`${API_BASE}/settings/project`);
|
|
27
|
-
if (!res.ok) return { name: '', logoUrl: '', timezone: 'UTC' };
|
|
27
|
+
if (!res.ok) return { name: '', logoUrl: '', timezone: 'UTC', maxRetries: 0 };
|
|
28
28
|
return res.json();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function saveProject({ name, logoUrl, timezone }) {
|
|
31
|
+
export async function saveProject({ name, logoUrl, timezone, maxRetries }) {
|
|
32
32
|
const res = await fetch(`${API_BASE}/settings/project`, {
|
|
33
33
|
method: 'POST',
|
|
34
34
|
headers: { 'Content-Type': 'application/json' },
|
|
35
|
-
body: JSON.stringify({ name, logoUrl, timezone })
|
|
35
|
+
body: JSON.stringify({ name, logoUrl, timezone, maxRetries })
|
|
36
36
|
});
|
|
37
37
|
return res.json();
|
|
38
38
|
}
|
|
@@ -41,6 +41,7 @@ export const REDIRECT_DELAY_MS = 3000;
|
|
|
41
41
|
|
|
42
42
|
export const WORKERS_MIN = 1;
|
|
43
43
|
export const WORKERS_MAX = 10;
|
|
44
|
+
export const MAX_TEST_RETRIES = 5;
|
|
44
45
|
export const RUN_PICKER_LIMIT = 200;
|
|
45
46
|
export const RUN_TAG_DISPLAY_LIMIT = 5;
|
|
46
47
|
export const CASE_HISTORY_BARS_MAX = 20;
|
|
@@ -430,6 +430,14 @@
|
|
|
430
430
|
{#if group.scenarios.length > 1}
|
|
431
431
|
<span class="scenario-count">{group.scenarios.length} cases</span>
|
|
432
432
|
{/if}
|
|
433
|
+
{#if group.scenarios[0]?.attempts > 1}
|
|
434
|
+
<span
|
|
435
|
+
class="scenario-count"
|
|
436
|
+
title="Failed and was automatically retried before the final result"
|
|
437
|
+
>
|
|
438
|
+
{group.scenarios[0].attempts} attempts
|
|
439
|
+
</span>
|
|
440
|
+
{/if}
|
|
433
441
|
</span>
|
|
434
442
|
|
|
435
443
|
<div class="scenario-tags">
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
updateRunner,
|
|
40
40
|
deleteRunner,
|
|
41
41
|
pingRunner,
|
|
42
|
-
probeRunner
|
|
42
|
+
probeRunner,
|
|
43
|
+
stopRunner,
|
|
44
|
+
restartRunner
|
|
43
45
|
} from '$lib/api/runners';
|
|
44
46
|
import { fetchPrefixes, savePrefixes, migratePrefixes } from '$lib/api/repository';
|
|
45
47
|
import { updateProfile, changePassword } from '$lib/api/auth';
|
|
@@ -51,7 +53,7 @@
|
|
|
51
53
|
import { builtInEnabled } from '$lib/stores/runner';
|
|
52
54
|
import { auth } from '$lib/stores/auth';
|
|
53
55
|
import { theme } from '$lib/stores/theme';
|
|
54
|
-
import { API_BASE, BROWSERS, TOAST_TIMEOUT_MS } from '$lib/constants';
|
|
56
|
+
import { API_BASE, BROWSERS, TOAST_TIMEOUT_MS, MAX_TEST_RETRIES } from '$lib/constants';
|
|
55
57
|
import Button from '$lib/components/ui/Button.svelte';
|
|
56
58
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
57
59
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
@@ -69,7 +71,7 @@
|
|
|
69
71
|
} catch {}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
let project = { name: '', logoUrl: '', timezone: 'UTC' };
|
|
74
|
+
let project = { name: '', logoUrl: '', timezone: 'UTC', maxRetries: 0 };
|
|
73
75
|
let projectSaving = false;
|
|
74
76
|
let toast = null;
|
|
75
77
|
|
|
@@ -142,6 +144,8 @@
|
|
|
142
144
|
let runnerFormSaving = false;
|
|
143
145
|
let runnerFormOpen = false;
|
|
144
146
|
let pingResults = {};
|
|
147
|
+
let stoppingId = null;
|
|
148
|
+
let restartingId = null;
|
|
145
149
|
let editingId = null;
|
|
146
150
|
let editForm = { name: '', url: '', token: '', browser: 'chromium' };
|
|
147
151
|
let editFormError = '';
|
|
@@ -280,6 +284,51 @@
|
|
|
280
284
|
}
|
|
281
285
|
}
|
|
282
286
|
|
|
287
|
+
async function refreshPing(id) {
|
|
288
|
+
pingResults = { ...pingResults, [id]: { loading: true } };
|
|
289
|
+
try {
|
|
290
|
+
const result = await pingRunner(id);
|
|
291
|
+
pingResults = { ...pingResults, [id]: { ...result, loading: false } };
|
|
292
|
+
} catch {
|
|
293
|
+
pingResults = { ...pingResults, [id]: { ok: false, error: 'Network error', loading: false } };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function handleStopRunner(id, name) {
|
|
298
|
+
stoppingId = id;
|
|
299
|
+
try {
|
|
300
|
+
const result = await stopRunner(id);
|
|
301
|
+
if (result.ok) {
|
|
302
|
+
showToast('success', `Runner "${name}" stopped.`);
|
|
303
|
+
} else {
|
|
304
|
+
showToast('error', `Could not stop "${name}": ${result.error ?? 'unknown error'}`);
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
showToast('error', `Could not stop "${name}".`);
|
|
308
|
+
} finally {
|
|
309
|
+
stoppingId = null;
|
|
310
|
+
refreshPing(id);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function handleRestartRunner(id, name) {
|
|
315
|
+
restartingId = id;
|
|
316
|
+
try {
|
|
317
|
+
const result = await restartRunner(id);
|
|
318
|
+
if (result.ok) {
|
|
319
|
+
showToast('success', `Runner "${name}" restarting…`);
|
|
320
|
+
} else {
|
|
321
|
+
showToast('error', `Could not restart "${name}": ${result.error ?? 'unknown error'}`);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
showToast('error', `Could not restart "${name}".`);
|
|
325
|
+
} finally {
|
|
326
|
+
restartingId = null;
|
|
327
|
+
// Give the replacement process a moment to bind before checking on it.
|
|
328
|
+
setTimeout(() => refreshPing(id), 2000);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
283
332
|
function startEdit(r) {
|
|
284
333
|
editingId = r.id;
|
|
285
334
|
editForm = { name: r.name, url: r.url, token: r.token, browser: r.browser };
|
|
@@ -717,6 +766,24 @@
|
|
|
717
766
|
</select>
|
|
718
767
|
</div>
|
|
719
768
|
|
|
769
|
+
<div class="field">
|
|
770
|
+
<label class="field-label" for="project-max-retries">
|
|
771
|
+
<span>Retry failed tests</span>
|
|
772
|
+
<span class="field-hint">
|
|
773
|
+
Automatically re-run failed scenarios up to this many times before finalizing the
|
|
774
|
+
report. 0 disables retries.
|
|
775
|
+
</span>
|
|
776
|
+
</label>
|
|
777
|
+
<input
|
|
778
|
+
id="project-max-retries"
|
|
779
|
+
type="number"
|
|
780
|
+
class="field-input"
|
|
781
|
+
min="0"
|
|
782
|
+
max={MAX_TEST_RETRIES}
|
|
783
|
+
bind:value={project.maxRetries}
|
|
784
|
+
/>
|
|
785
|
+
</div>
|
|
786
|
+
|
|
720
787
|
<!-- Dark mode toggle -->
|
|
721
788
|
<div class="toggle-row">
|
|
722
789
|
<div class="toggle-info">
|
|
@@ -878,6 +945,20 @@
|
|
|
878
945
|
<p class="runner-card-url">{r.url}</p>
|
|
879
946
|
<div class="runner-card-actions">
|
|
880
947
|
<Button variant="ghost" size="sm" on:click={() => startEdit(r)}>Edit</Button>
|
|
948
|
+
<Button
|
|
949
|
+
variant="ghost"
|
|
950
|
+
size="sm"
|
|
951
|
+
disabled={restartingId === r.id}
|
|
952
|
+
on:click={() => handleRestartRunner(r.id, r.name)}
|
|
953
|
+
>{restartingId === r.id ? 'Restarting…' : 'Restart'}</Button
|
|
954
|
+
>
|
|
955
|
+
<Button
|
|
956
|
+
variant="ghost"
|
|
957
|
+
size="sm"
|
|
958
|
+
disabled={stoppingId === r.id}
|
|
959
|
+
on:click={() => handleStopRunner(r.id, r.name)}
|
|
960
|
+
>{stoppingId === r.id ? 'Stopping…' : 'Stop'}</Button
|
|
961
|
+
>
|
|
881
962
|
<Button
|
|
882
963
|
variant="danger"
|
|
883
964
|
size="sm"
|
|
@@ -1985,6 +2066,7 @@
|
|
|
1985
2066
|
.runner-card-actions {
|
|
1986
2067
|
display: flex;
|
|
1987
2068
|
align-items: center;
|
|
2069
|
+
flex-wrap: wrap;
|
|
1988
2070
|
gap: 0.375rem;
|
|
1989
2071
|
padding-left: calc(13px + 0.5rem);
|
|
1990
2072
|
margin-top: 0.125rem;
|