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.
@@ -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
- function runBuiltIn(
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
- proc.stdout.on('data', (d) => {
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
- proc.on('close', async (code) => {
216
- clearInterval(ssPoller);
217
- fs.rm(ssDir, { recursive: true, force: true }, () => {});
218
- activeProcs.delete(proc);
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 ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}-${i}`);
381
- fs.mkdirSync(ssDir, { recursive: true });
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 proc = spawn('npm', ['run', 'test'], { env, shell: true });
395
- activeProcs.add(proc);
396
-
397
- const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
398
- socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
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
- proc.stdout.on('data', (d) => {
402
- const text = d.toString();
403
- laneLogs[laneId] += text;
404
- socket.emit('runner-lane-log', { id: laneId, log: text });
405
- });
406
- proc.stderr.on('data', (d) => {
407
- const text = `[ERROR] ${d.toString()}`;
408
- laneLogs[laneId] += text;
409
- socket.emit('runner-lane-log', { id: laneId, log: text });
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
- const idx = i;
413
- proc.on('close', (code) => {
414
- clearInterval(ssPoller);
415
- fs.rm(ssDir, { recursive: true, force: true }, () => {});
416
- activeProcs.delete(proc);
417
- const content =
418
- readCucumberReportFile() ??
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
- runnerService.dispatchAndPoll(
426
- laneId,
427
- { tags: chunkTag, browser, workers },
428
- (log) => {
429
- laneLogs[laneId] += log;
430
- socket.emit('runner-lane-log', { id: laneId, log });
431
- },
432
- (code, content) =>
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
- code,
437
- content ??
438
- makeSyntheticFailReport(lane.name, chunkIds, 'could not fetch report from runner')
439
- ),
440
- ({ stepName, data }) => {
441
- socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"