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.
@@ -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 env = {
77
- ...process.env,
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
- task.stdout.on('data', (d) => {
92
- process.stdout.write(d);
93
- if (_io) _io.emit('bg-run-log', { runId: taskName, log: d.toString() });
94
- });
95
- task.stderr.on('data', (d) => {
96
- process.stderr.write(d);
97
- if (_io) _io.emit('bg-run-log', { runId: taskName, log: `[ERROR] ${d.toString()}` });
98
- });
99
- task.on('close', (code) => {
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 ssDir = path.join(os.tmpdir(), `plum-cron-ss-${Date.now()}-${i}`);
239
- fs.mkdirSync(ssDir, { recursive: true });
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 task = spawn('npm', ['run', 'test'], { env, shell: true });
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
- const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
254
- if (_io) _io.emit('bg-run-lane-screenshot', { runId: taskName, laneId, stepName, data });
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
- task.stdout.on('data', (d) => {
258
- process.stdout.write(d);
259
- if (_io) _io.emit('bg-run-lane-log', { runId: taskName, laneId, log: d.toString() });
260
- });
261
- task.stderr.on('data', (d) => {
262
- const text = `[ERROR] ${d.toString()}`;
263
- process.stderr.write(d);
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
- runnerService.dispatchAndPoll(
275
- lane.id,
276
- { tags: chunkTag, browser, workers },
277
- (log) => {
278
- process.stdout.write(log);
279
- if (_io) _io.emit('bg-run-lane-log', { runId: taskName, laneId, log });
280
- },
281
- (code, content) => onLaneDone(idx, laneId, code, content),
282
- ({ stepName, data }) => {
283
- if (_io) _io.emit('bg-run-lane-screenshot', { runId: taskName, laneId, stepName, data });
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 },