plum-e2e 2.8.6 → 2.9.0
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/_scaffold/utils/browser.ts +184 -30
- package/backend/_scaffold/utils/hooks.ts +37 -8
- package/backend/app.js +0 -5
- package/backend/config/scripts/generate-report.js +2 -2
- package/backend/constants/socketEvents.js +7 -4
- package/backend/lib/reportFilename.js +1 -2
- package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
- package/backend/lib/serverBootstrap.js +14 -0
- package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
- package/backend/mcp/server.js +3 -47
- package/backend/package-lock.json +199 -1
- package/backend/package.json +3 -1
- package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
- package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
- package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
- package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
- package/backend/prisma/schema.prisma +97 -70
- package/backend/routes/backup.routes.js +47 -1
- package/backend/routes/reports.routes.js +23 -0
- package/backend/server.js +1 -1
- package/backend/services/backupCronService.js +1 -1
- package/backend/services/backupService.js +134 -44
- package/backend/services/cronService.js +23 -15
- package/backend/services/nodeExecutionService.js +41 -15
- package/backend/services/nodeStreamRegistry.js +24 -0
- package/backend/services/reportService.js +167 -83
- package/backend/services/runnerService.js +19 -7
- package/backend/services/settingsService.js +6 -3
- package/backend/services/triggerService.js +20 -13
- package/backend/websockets/nodeSocketHandler.js +40 -0
- package/backend/websockets/socketHandler.js +9 -7
- package/frontend/.svelte-kit/generated/server/internal.js +1 -1
- package/frontend/package-lock.json +121 -32
- package/frontend/package.json +1 -0
- package/frontend/src/lib/api/reports.js +13 -4
- package/frontend/src/lib/api/settings.js +16 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
- package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
- package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
- package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
- package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
- package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
- package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
- package/frontend/src/lib/constants.js +0 -1
- package/frontend/src/lib/copy/reports.js +18 -8
- package/frontend/src/lib/copy/settings.js +25 -4
- package/frontend/src/lib/socketEvents.js +7 -4
- package/frontend/src/lib/stores/runner.js +26 -3
- package/frontend/src/lib/styles/tokens.css +7 -0
- package/frontend/src/lib/utils/format.js +108 -2
- package/frontend/src/lib/utils/inspectElement.js +34 -0
- package/frontend/src/routes/reports/+page.svelte +79 -1
- package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
- package/frontend/src/routes/reports/live/+page.svelte +236 -260
- package/frontend/src/routes/settings/+page.svelte +246 -8
- package/package.json +1 -1
- package/backend/playwright.config.js +0 -85
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const
|
|
8
|
+
const zlib = require('zlib');
|
|
9
9
|
const prisma = require('./prisma');
|
|
10
10
|
const { isScheduledTrigger, normaliseTrigger } = require('../constants/triggers');
|
|
11
11
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
12
12
|
const { REPORT_STATUS } = require('../constants/jobStatus');
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
// Matched by string literal in backend/tests/utils/browser.ts (flushRecordings) —
|
|
15
|
+
// the two runtimes don't share a module, mirroring how 'image/png' is already
|
|
16
|
+
// duplicated between the two files.
|
|
17
|
+
const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
|
|
18
|
+
// Small, always-attached marker (independent of whether any tab actually
|
|
19
|
+
// recorded events) so a scenario's worker is always recoverable for grouping,
|
|
20
|
+
// even on an instant-failure scenario with an empty recording.
|
|
21
|
+
const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
|
|
14
22
|
|
|
15
23
|
// ---------------------------------------------------------------------------
|
|
16
24
|
// Auto-sync: mark test cases as automated and record history from Cucumber tags
|
|
@@ -101,22 +109,6 @@ async function resolveCronJobId(triggerType) {
|
|
|
101
109
|
return job?.id ?? null;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
/**
|
|
105
|
-
* Walks a screenshot filename out of the content JSON tree.
|
|
106
|
-
* Used when deleting reports to clean up screenshot files.
|
|
107
|
-
*/
|
|
108
|
-
function collectScreenshotFiles(content) {
|
|
109
|
-
const files = [];
|
|
110
|
-
for (const feature of content?.features ?? []) {
|
|
111
|
-
for (const scenario of feature?.scenarios ?? []) {
|
|
112
|
-
for (const step of scenario?.steps ?? []) {
|
|
113
|
-
if (step.screenshot) files.push(step.screenshot);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return files;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
112
|
/**
|
|
121
113
|
* Stable identity for a Cucumber feature across distributed lanes. Dispatched
|
|
122
114
|
* runs report an absolute temp uri (…/plum-job-<uuid>/features/Login.feature)
|
|
@@ -139,10 +131,36 @@ function scenarioIdTag(scenario) {
|
|
|
139
131
|
return (scenario.tags ?? []).map((t) => t.name).find(isTestCaseTag) ?? null;
|
|
140
132
|
}
|
|
141
133
|
|
|
134
|
+
// Cucumber's legacy JSON `id` is identical for every Examples row of a Scenario
|
|
135
|
+
// Outline — `line` (the row's own line in the feature file) is the only field
|
|
136
|
+
// that actually differs between them, so combine the two for a truly unique key.
|
|
137
|
+
function scenarioUniqueId(scenario) {
|
|
138
|
+
return `${scenario.id};;${scenario.line}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
142
141
|
function scenarioFailed(scenario) {
|
|
143
142
|
return (scenario.steps ?? []).some((s) => s.result?.status === 'failed');
|
|
144
143
|
}
|
|
145
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Reads the worker-id marker browser.ts attaches unconditionally in the After
|
|
147
|
+
* hook (separate from rrweb recordings, which can legitimately be empty).
|
|
148
|
+
*/
|
|
149
|
+
function extractWorkerId(scenario) {
|
|
150
|
+
const marker = (scenario.steps || [])
|
|
151
|
+
.filter((s) => s.hidden)
|
|
152
|
+
.flatMap(
|
|
153
|
+
(step) => step.embeddings?.filter((e) => e.mime_type === WORKER_META_MIME_TYPE) ?? []
|
|
154
|
+
)[0];
|
|
155
|
+
if (!marker) return 1;
|
|
156
|
+
try {
|
|
157
|
+
const { workerId } = JSON.parse(Buffer.from(marker.data, 'base64').toString('utf8'));
|
|
158
|
+
return Number.isFinite(workerId) ? workerId : 1;
|
|
159
|
+
} catch {
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
/**
|
|
147
165
|
* Test-ID tags (see scenarioIdTag) of every failed scenario in a raw Cucumber
|
|
148
166
|
* JSON payload, deduped. Used to scope the next retry attempt to just the
|
|
@@ -178,7 +196,9 @@ function mergeRawAttempt(accumulated, attemptRawJson, round, attemptsMap) {
|
|
|
178
196
|
accumulated.push(accFeature);
|
|
179
197
|
}
|
|
180
198
|
for (const scenario of feature.elements ?? []) {
|
|
181
|
-
accFeature.elements = accFeature.elements.filter(
|
|
199
|
+
accFeature.elements = accFeature.elements.filter(
|
|
200
|
+
(e) => scenarioUniqueId(e) !== scenarioUniqueId(scenario)
|
|
201
|
+
);
|
|
182
202
|
accFeature.elements.push(scenario);
|
|
183
203
|
const idTag = scenarioIdTag(scenario);
|
|
184
204
|
if (idTag) attemptsMap[idTag] = round;
|
|
@@ -187,61 +207,61 @@ function mergeRawAttempt(accumulated, attemptRawJson, round, attemptsMap) {
|
|
|
187
207
|
return accumulated;
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Parses one scenario's hidden hook-step rrweb attachments (flushed from
|
|
212
|
+
* browser.ts's flushRecordings via the After hook) into Recording rows ready
|
|
213
|
+
* for prisma.recording.createMany, keyed to this scenario via scenarioUniqueId.
|
|
214
|
+
*/
|
|
215
|
+
function extractRecordings(scenario) {
|
|
216
|
+
const hookRecordings = (scenario.steps || [])
|
|
217
|
+
.filter((s) => s.hidden)
|
|
218
|
+
.flatMap((step) => step.embeddings?.filter((e) => e.mime_type === RRWEB_MIME_TYPE) ?? []);
|
|
219
|
+
|
|
220
|
+
return hookRecordings
|
|
221
|
+
.map((embedding) => {
|
|
222
|
+
try {
|
|
223
|
+
const decompressed = zlib.gunzipSync(Buffer.from(embedding.data, 'base64'));
|
|
224
|
+
const { workerId, tabId, tabIndex, events, openedAt, closedAt } = JSON.parse(
|
|
225
|
+
decompressed.toString('utf8')
|
|
226
|
+
);
|
|
227
|
+
return {
|
|
228
|
+
scenarioId: scenarioUniqueId(scenario),
|
|
229
|
+
workerId,
|
|
230
|
+
tabId,
|
|
231
|
+
tabIndex,
|
|
232
|
+
// Real Playwright open/close times (browser.ts), not inferred from the
|
|
233
|
+
// events themselves — a static page can go quiet, or emit nothing at
|
|
234
|
+
// all, long before it actually closes, which made the last-event
|
|
235
|
+
// timestamp an unreliable stand-in for how long a tab stayed relevant.
|
|
236
|
+
startedAt: BigInt(openedAt ?? events[0]?.timestamp ?? 0),
|
|
237
|
+
endedAt: BigInt(closedAt ?? events[events.length - 1]?.timestamp ?? 0),
|
|
238
|
+
events: zlib.gzipSync(Buffer.from(JSON.stringify(events), 'utf8'))
|
|
239
|
+
};
|
|
240
|
+
} catch (e) {
|
|
241
|
+
console.error(`[report] Failed to parse rrweb recording: ${e.message}`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.filter(Boolean);
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
/**
|
|
200
249
|
* Transforms raw Cucumber JSON into our stored format:
|
|
201
250
|
* - Resolves pass/fail status per step/scenario/feature
|
|
202
|
-
* - Extracts
|
|
203
|
-
* - Stores only the filename in the content (not the base64 blob)
|
|
251
|
+
* - Extracts rrweb recordings attached via the After hook
|
|
204
252
|
*
|
|
205
|
-
* Returns { features, status } where status is 'PASS' | 'FAIL'.
|
|
253
|
+
* Returns { features, status, recordings } where status is 'PASS' | 'FAIL'.
|
|
206
254
|
*/
|
|
207
255
|
function processCucumberJson(raw, attempts = {}) {
|
|
208
|
-
|
|
256
|
+
const recordings = [];
|
|
209
257
|
|
|
210
258
|
const features = raw.map((feature) => {
|
|
211
259
|
const scenarios = (feature.elements || []).map((scenario) => {
|
|
260
|
+
recordings.push(...extractRecordings(scenario));
|
|
261
|
+
|
|
212
262
|
const visibleSteps = (scenario.steps || []).filter((s) => !s.hidden);
|
|
213
|
-
const hookScreenshots = (scenario.steps || [])
|
|
214
|
-
.filter((s) => s.hidden)
|
|
215
|
-
.flatMap((step) => step.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []);
|
|
216
|
-
const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
|
|
217
|
-
|
|
218
|
-
const steps = visibleSteps.map((step, index) => {
|
|
219
|
-
// AfterStep hook attachments land in step.after[].embeddings in Cucumber.js JSON
|
|
220
|
-
const afterStepScreenshot =
|
|
221
|
-
(step.after ?? []).flatMap(
|
|
222
|
-
(a) => a.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []
|
|
223
|
-
)[0]?.data ?? null;
|
|
224
|
-
|
|
225
|
-
const screenshotData =
|
|
226
|
-
step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
|
|
227
|
-
afterStepScreenshot ??
|
|
228
|
-
(index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
|
|
229
|
-
null;
|
|
230
|
-
|
|
231
|
-
let screenshotFile = null;
|
|
232
|
-
if (screenshotData) {
|
|
233
|
-
screenshotFile = `${crypto.randomUUID()}.png`;
|
|
234
|
-
try {
|
|
235
|
-
fs.writeFileSync(
|
|
236
|
-
path.join(SCREENSHOTS_DIR, screenshotFile),
|
|
237
|
-
Buffer.from(screenshotData, 'base64')
|
|
238
|
-
);
|
|
239
|
-
} catch (e) {
|
|
240
|
-
console.error(`[report] Failed to write screenshot: ${e.message}`);
|
|
241
|
-
screenshotFile = null;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
263
|
|
|
264
|
+
const steps = visibleSteps.map((step) => {
|
|
245
265
|
const rawStatus = step.result?.status ?? 'pending';
|
|
246
266
|
// Undefined/ambiguous steps rank below 'failed' otherwise, so a step
|
|
247
267
|
// definition mismatch reports as passing instead of failing.
|
|
@@ -254,7 +274,7 @@ function processCucumberJson(raw, attempts = {}) {
|
|
|
254
274
|
status,
|
|
255
275
|
duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
|
|
256
276
|
error: step.result?.error_message ?? null,
|
|
257
|
-
|
|
277
|
+
dataTable: step.arguments?.[0]?.rows?.map((row) => row.cells) ?? null
|
|
258
278
|
};
|
|
259
279
|
});
|
|
260
280
|
|
|
@@ -264,12 +284,15 @@ function processCucumberJson(raw, attempts = {}) {
|
|
|
264
284
|
}, 'passed');
|
|
265
285
|
|
|
266
286
|
return {
|
|
287
|
+
id: scenarioUniqueId(scenario),
|
|
267
288
|
name: scenario.name,
|
|
268
289
|
keyword: scenario.keyword,
|
|
269
290
|
tags: (scenario.tags ?? []).map((t) => t.name),
|
|
270
291
|
status: worstStatus,
|
|
271
292
|
duration: steps.reduce((s, st) => s + st.duration, 0),
|
|
272
293
|
attempts: attempts[scenarioIdTag(scenario)] ?? 1,
|
|
294
|
+
workerId: extractWorkerId(scenario),
|
|
295
|
+
runnerName: scenario.__plumRunnerName ?? null,
|
|
273
296
|
steps
|
|
274
297
|
};
|
|
275
298
|
});
|
|
@@ -283,7 +306,7 @@ function processCucumberJson(raw, attempts = {}) {
|
|
|
283
306
|
});
|
|
284
307
|
|
|
285
308
|
const hasFailures = features.some((f) => f.status === 'failed');
|
|
286
|
-
return { features, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
|
|
309
|
+
return { features, recordings, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
|
|
287
310
|
}
|
|
288
311
|
|
|
289
312
|
// ---------------------------------------------------------------------------
|
|
@@ -295,7 +318,8 @@ const reportListSelect = {
|
|
|
295
318
|
status: true,
|
|
296
319
|
tags: true,
|
|
297
320
|
triggerType: true,
|
|
298
|
-
|
|
321
|
+
runnerCount: true,
|
|
322
|
+
workerCount: true,
|
|
299
323
|
browser: true,
|
|
300
324
|
runnerName: true,
|
|
301
325
|
createdAt: true,
|
|
@@ -340,7 +364,8 @@ const getReportDetail = async (id) => {
|
|
|
340
364
|
status: true,
|
|
341
365
|
tags: true,
|
|
342
366
|
triggerType: true,
|
|
343
|
-
|
|
367
|
+
runnerCount: true,
|
|
368
|
+
workerCount: true,
|
|
344
369
|
browser: true,
|
|
345
370
|
runnerName: true,
|
|
346
371
|
createdAt: true,
|
|
@@ -355,6 +380,47 @@ const getReportDetail = async (id) => {
|
|
|
355
380
|
return { ...meta, features: content?.features ?? [] };
|
|
356
381
|
};
|
|
357
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Metadata for every recording on a report — deliberately excludes `events`
|
|
385
|
+
* (can be large) so the replay UI can work out tab timing/order before
|
|
386
|
+
* fetching any actual event data.
|
|
387
|
+
*/
|
|
388
|
+
const getRecordings = async (reportId) => {
|
|
389
|
+
const recordings = await prisma.recording.findMany({
|
|
390
|
+
where: { reportId },
|
|
391
|
+
select: {
|
|
392
|
+
id: true,
|
|
393
|
+
scenarioId: true,
|
|
394
|
+
workerId: true,
|
|
395
|
+
tabId: true,
|
|
396
|
+
tabIndex: true,
|
|
397
|
+
startedAt: true,
|
|
398
|
+
endedAt: true
|
|
399
|
+
},
|
|
400
|
+
orderBy: { tabIndex: 'asc' }
|
|
401
|
+
});
|
|
402
|
+
// BigInt doesn't survive JSON.stringify — both fit safely in a JS Number
|
|
403
|
+
// (epoch ms is well under Number.MAX_SAFE_INTEGER).
|
|
404
|
+
return recordings.map((r) => ({
|
|
405
|
+
...r,
|
|
406
|
+
startedAt: r.startedAt === null ? null : Number(r.startedAt),
|
|
407
|
+
endedAt: r.endedAt === null ? null : Number(r.endedAt)
|
|
408
|
+
}));
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Decompresses one recording's rrweb event array, fetched lazily by the
|
|
413
|
+
* replay UI only once a tab is actually selected for playback.
|
|
414
|
+
*/
|
|
415
|
+
const getRecordingEvents = async (recordingId) => {
|
|
416
|
+
const recording = await prisma.recording.findUnique({
|
|
417
|
+
where: { id: recordingId },
|
|
418
|
+
select: { events: true }
|
|
419
|
+
});
|
|
420
|
+
if (!recording) return null;
|
|
421
|
+
return JSON.parse(zlib.gunzipSync(recording.events).toString('utf8'));
|
|
422
|
+
};
|
|
423
|
+
|
|
358
424
|
// ---------------------------------------------------------------------------
|
|
359
425
|
// Write operations
|
|
360
426
|
// ---------------------------------------------------------------------------
|
|
@@ -366,7 +432,8 @@ const getReportDetail = async (id) => {
|
|
|
366
432
|
* rawCucumberJson: object[],
|
|
367
433
|
* tags: string,
|
|
368
434
|
* triggerType: string,
|
|
369
|
-
*
|
|
435
|
+
* runnerCount?: number,
|
|
436
|
+
* workerCount?: number,
|
|
370
437
|
* browser?: string,
|
|
371
438
|
* runnerName?: string,
|
|
372
439
|
* runnerId?: string,
|
|
@@ -377,7 +444,8 @@ const saveReport = async ({
|
|
|
377
444
|
rawCucumberJson,
|
|
378
445
|
tags,
|
|
379
446
|
triggerType,
|
|
380
|
-
|
|
447
|
+
runnerCount = 1,
|
|
448
|
+
workerCount = 1,
|
|
381
449
|
browser,
|
|
382
450
|
runnerName,
|
|
383
451
|
runnerId,
|
|
@@ -388,7 +456,11 @@ const saveReport = async ({
|
|
|
388
456
|
attempts = {}
|
|
389
457
|
}) => {
|
|
390
458
|
const normTrigger = normaliseTrigger(triggerType);
|
|
391
|
-
const {
|
|
459
|
+
const {
|
|
460
|
+
features,
|
|
461
|
+
recordings,
|
|
462
|
+
status: derivedStatus
|
|
463
|
+
} = processCucumberJson(rawCucumberJson, attempts);
|
|
392
464
|
const status = forceFail ? REPORT_STATUS.FAIL : derivedStatus;
|
|
393
465
|
const cronJobId = await resolveCronJobId(normTrigger);
|
|
394
466
|
|
|
@@ -397,7 +469,8 @@ const saveReport = async ({
|
|
|
397
469
|
status,
|
|
398
470
|
tags: (tags ?? '').replace(/^\(|\)$/g, '') || '@all-tests',
|
|
399
471
|
triggerType: normTrigger,
|
|
400
|
-
|
|
472
|
+
runnerCount,
|
|
473
|
+
workerCount,
|
|
401
474
|
browser: browser ?? DEFAULT_BROWSER,
|
|
402
475
|
runnerName: runnerName ?? null,
|
|
403
476
|
runnerId: runnerId ?? null,
|
|
@@ -408,17 +481,23 @@ const saveReport = async ({
|
|
|
408
481
|
duration
|
|
409
482
|
}
|
|
410
483
|
});
|
|
484
|
+
if (recordings.length > 0) {
|
|
485
|
+
await prisma.recording.createMany({
|
|
486
|
+
data: recordings.map((r) => ({ ...r, reportId: report.id }))
|
|
487
|
+
});
|
|
488
|
+
}
|
|
411
489
|
syncAutomatedTags(report.id, features, testRunId ?? null);
|
|
412
490
|
return report;
|
|
413
491
|
};
|
|
414
492
|
|
|
415
493
|
/**
|
|
416
|
-
* Merges Cucumber JSON from all distributed lanes
|
|
417
|
-
*
|
|
494
|
+
* Merges Cucumber JSON from all distributed lanes and persists one combined
|
|
495
|
+
* report to the database.
|
|
418
496
|
*
|
|
419
497
|
* @param {{
|
|
420
498
|
* reports: (string|null)[],
|
|
421
499
|
* runners: { id: string, name: string, dbId: string|null }[],
|
|
500
|
+
* workers?: number,
|
|
422
501
|
* overallCode: number,
|
|
423
502
|
* tag: string,
|
|
424
503
|
* triggerType: string,
|
|
@@ -429,6 +508,7 @@ const saveReport = async ({
|
|
|
429
508
|
const saveCombinedReport = async ({
|
|
430
509
|
reports,
|
|
431
510
|
runners,
|
|
511
|
+
workers = 1,
|
|
432
512
|
overallCode,
|
|
433
513
|
tag,
|
|
434
514
|
triggerType,
|
|
@@ -439,15 +519,23 @@ const saveCombinedReport = async ({
|
|
|
439
519
|
attemptsByLane = null
|
|
440
520
|
}) => {
|
|
441
521
|
const featureMap = new Map();
|
|
442
|
-
|
|
443
|
-
if (!content)
|
|
522
|
+
reports.forEach((content, laneIdx) => {
|
|
523
|
+
if (!content) return;
|
|
444
524
|
let parsed;
|
|
445
525
|
try {
|
|
446
526
|
parsed = JSON.parse(content);
|
|
447
527
|
} catch {
|
|
448
|
-
|
|
528
|
+
return;
|
|
449
529
|
}
|
|
530
|
+
const lane = runners[laneIdx];
|
|
450
531
|
for (const feature of parsed) {
|
|
532
|
+
// Merging loses which lane a scenario came from — the raw Cucumber JSON
|
|
533
|
+
// has no such field — so stamp it here, before the merge, while we still
|
|
534
|
+
// know. processCucumberJson reads this to group the report's scenario
|
|
535
|
+
// list by runner.
|
|
536
|
+
for (const scenario of feature.elements ?? []) {
|
|
537
|
+
scenario.__plumRunnerName = lane?.name ?? null;
|
|
538
|
+
}
|
|
451
539
|
// Each lane runs from its own temp dir, so the same feature reports a
|
|
452
540
|
// different absolute uri per runner. Key on the path from `features/`
|
|
453
541
|
// onward (falling back to name) so one feature's scenarios from every
|
|
@@ -459,7 +547,7 @@ const saveCombinedReport = async ({
|
|
|
459
547
|
featureMap.set(key, { ...feature, elements: [...(feature.elements ?? [])] });
|
|
460
548
|
}
|
|
461
549
|
}
|
|
462
|
-
}
|
|
550
|
+
});
|
|
463
551
|
const combined = [...featureMap.values()];
|
|
464
552
|
|
|
465
553
|
let combinedLogs = null;
|
|
@@ -478,7 +566,8 @@ const saveCombinedReport = async ({
|
|
|
478
566
|
rawCucumberJson: combined,
|
|
479
567
|
tags: tag,
|
|
480
568
|
triggerType,
|
|
481
|
-
|
|
569
|
+
runnerCount: runners.length,
|
|
570
|
+
workerCount: workers,
|
|
482
571
|
browser,
|
|
483
572
|
runnerName: runners.map((r) => r.name).join(', '),
|
|
484
573
|
runnerId: null,
|
|
@@ -508,17 +597,10 @@ const attachDurationToLatestReport = async ({ afterTimestamp, duration }) => {
|
|
|
508
597
|
// ---------------------------------------------------------------------------
|
|
509
598
|
|
|
510
599
|
const deleteReport = async (id) => {
|
|
511
|
-
const report = await prisma.report.findUnique({ where: { id }, select: { content: true } });
|
|
512
|
-
if (report) deleteScreenshotFiles(report.content);
|
|
513
600
|
await prisma.report.delete({ where: { id } });
|
|
514
601
|
};
|
|
515
602
|
|
|
516
603
|
const deleteReports = async (ids) => {
|
|
517
|
-
const reports = await prisma.report.findMany({
|
|
518
|
-
where: { id: { in: ids } },
|
|
519
|
-
select: { content: true }
|
|
520
|
-
});
|
|
521
|
-
for (const r of reports) deleteScreenshotFiles(r.content);
|
|
522
604
|
await prisma.report.deleteMany({ where: { id: { in: ids } } });
|
|
523
605
|
};
|
|
524
606
|
|
|
@@ -546,6 +628,8 @@ module.exports = {
|
|
|
546
628
|
getReports,
|
|
547
629
|
getLatestReportId,
|
|
548
630
|
getReportDetail,
|
|
631
|
+
getRecordings,
|
|
632
|
+
getRecordingEvents,
|
|
549
633
|
saveReport,
|
|
550
634
|
saveCombinedReport,
|
|
551
635
|
attachDurationToLatestReport,
|
|
@@ -11,6 +11,8 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
|
|
|
11
11
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
12
12
|
const { bearerHeader } = require('../lib/authHeader');
|
|
13
13
|
const { JOB_STATUS } = require('../constants/jobStatus');
|
|
14
|
+
const settingsService = require('./settingsService');
|
|
15
|
+
const nodeStreamRegistry = require('./nodeStreamRegistry');
|
|
14
16
|
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// Runner CRUD
|
|
@@ -198,7 +200,7 @@ async function dispatchAndPoll(
|
|
|
198
200
|
{ tags, browser, workers },
|
|
199
201
|
onLog,
|
|
200
202
|
onDone,
|
|
201
|
-
|
|
203
|
+
onRRwebBatch = null
|
|
202
204
|
) {
|
|
203
205
|
// The async poll callback can overlap if a tick takes longer than the interval;
|
|
204
206
|
// guard so the run resolves exactly once and can't be finalised while a lane
|
|
@@ -207,6 +209,7 @@ async function dispatchAndPoll(
|
|
|
207
209
|
const finish = (code, content) => {
|
|
208
210
|
if (settled) return;
|
|
209
211
|
settled = true;
|
|
212
|
+
if (jobId) nodeStreamRegistry.unregisterRelay(jobId);
|
|
210
213
|
onDone(code, content);
|
|
211
214
|
};
|
|
212
215
|
|
|
@@ -217,6 +220,12 @@ async function dispatchAndPoll(
|
|
|
217
220
|
return;
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// The node needs to know where to open its own socket back to us — without
|
|
224
|
+
// this it has no way to reach the primary, since today only the reverse
|
|
225
|
+
// (primary knowing each node's url) is configured.
|
|
226
|
+
const { notifyPublicUrl } = await settingsService.getWebhooks();
|
|
227
|
+
const primaryUrl = notifyPublicUrl ? notifyPublicUrl.replace(/\/$/, '') : null;
|
|
228
|
+
|
|
220
229
|
let jobId;
|
|
221
230
|
try {
|
|
222
231
|
const res = await fetch(`${runner.url}/api/execute`, {
|
|
@@ -230,7 +239,8 @@ async function dispatchAndPoll(
|
|
|
230
239
|
browser,
|
|
231
240
|
workers,
|
|
232
241
|
tests: collectTestFiles(),
|
|
233
|
-
env: loadTestEnv(process.cwd())
|
|
242
|
+
env: loadTestEnv(process.cwd()),
|
|
243
|
+
...(primaryUrl ? { primaryUrl } : {})
|
|
234
244
|
}),
|
|
235
245
|
signal: AbortSignal.timeout(10000)
|
|
236
246
|
});
|
|
@@ -244,6 +254,12 @@ async function dispatchAndPoll(
|
|
|
244
254
|
|
|
245
255
|
onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
|
|
246
256
|
|
|
257
|
+
// With a primaryUrl configured, the node streams logs/rrweb events over its
|
|
258
|
+
// own socket instead — draining them from the poll too would duplicate them.
|
|
259
|
+
if (primaryUrl) {
|
|
260
|
+
nodeStreamRegistry.registerRelay(jobId, { onRRwebBatch, onLog });
|
|
261
|
+
}
|
|
262
|
+
|
|
247
263
|
let logOffset = 0;
|
|
248
264
|
let polling = false;
|
|
249
265
|
const poll = setInterval(async () => {
|
|
@@ -257,15 +273,11 @@ async function dispatchAndPoll(
|
|
|
257
273
|
if (!res.ok) return;
|
|
258
274
|
const body = await res.json();
|
|
259
275
|
|
|
260
|
-
if (body.logs) {
|
|
276
|
+
if (!primaryUrl && body.logs) {
|
|
261
277
|
onLog(body.logs);
|
|
262
278
|
logOffset += body.logs.length;
|
|
263
279
|
}
|
|
264
280
|
|
|
265
|
-
if (onScreenshot && Array.isArray(body.screenshots)) {
|
|
266
|
-
for (const ss of body.screenshots) onScreenshot(ss);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
281
|
if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
|
|
270
282
|
clearInterval(poll);
|
|
271
283
|
const content = await fetchReportContent(runner, jobId, onLog);
|
|
@@ -97,7 +97,8 @@ const getBackupConfig = async () => {
|
|
|
97
97
|
backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
|
|
98
98
|
backupS3Prefix: project.backupS3Prefix,
|
|
99
99
|
backupLastRunAt: project.backupLastRunAt,
|
|
100
|
-
backupLastStatus: project.backupLastStatus
|
|
100
|
+
backupLastStatus: project.backupLastStatus,
|
|
101
|
+
backupIncludeReports: project.backupIncludeReports
|
|
101
102
|
};
|
|
102
103
|
};
|
|
103
104
|
|
|
@@ -109,7 +110,8 @@ const updateBackupConfig = async ({
|
|
|
109
110
|
backupS3Bucket,
|
|
110
111
|
backupS3AccessKey,
|
|
111
112
|
backupS3SecretKey,
|
|
112
|
-
backupS3Prefix
|
|
113
|
+
backupS3Prefix,
|
|
114
|
+
backupIncludeReports
|
|
113
115
|
}) => {
|
|
114
116
|
const update = {
|
|
115
117
|
...(backupEnabled !== undefined && { backupEnabled }),
|
|
@@ -119,7 +121,8 @@ const updateBackupConfig = async ({
|
|
|
119
121
|
...(backupS3Bucket !== undefined && { backupS3Bucket }),
|
|
120
122
|
...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
|
|
121
123
|
...(backupS3SecretKey && { backupS3SecretKey }),
|
|
122
|
-
...(backupS3Prefix !== undefined && { backupS3Prefix })
|
|
124
|
+
...(backupS3Prefix !== undefined && { backupS3Prefix }),
|
|
125
|
+
...(backupIncludeReports !== undefined && { backupIncludeReports })
|
|
123
126
|
};
|
|
124
127
|
return prisma.project.upsert({
|
|
125
128
|
where: { id: 1 },
|
|
@@ -8,8 +8,8 @@ const fs = require('fs');
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const { spawn } = require('child_process');
|
|
10
10
|
const { randomUUID } = require('crypto');
|
|
11
|
-
const {
|
|
12
|
-
const { TRIGGER_TYPE } = require('../constants/triggers');
|
|
11
|
+
const { startRRwebPoller } = require('../lib/rrwebPoller');
|
|
12
|
+
const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID } = require('../constants/triggers');
|
|
13
13
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
14
14
|
const { PLUM_MODE_NODE } = require('../constants/env');
|
|
15
15
|
const { SOCKET_EVENTS } = require('../constants/socketEvents');
|
|
@@ -52,7 +52,7 @@ function runAttempt({
|
|
|
52
52
|
baseUrl,
|
|
53
53
|
suppressSave,
|
|
54
54
|
onLog,
|
|
55
|
-
|
|
55
|
+
onRRwebBatch
|
|
56
56
|
}) {
|
|
57
57
|
return new Promise((resolve) => {
|
|
58
58
|
const ssDir = path.join(os.tmpdir(), `plum-trigger-ss-${jobId}-${Date.now()}`);
|
|
@@ -73,7 +73,7 @@ function runAttempt({
|
|
|
73
73
|
|
|
74
74
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
|
|
75
75
|
|
|
76
|
-
const ssPoller =
|
|
76
|
+
const ssPoller = startRRwebPoller(ssDir, onRRwebBatch);
|
|
77
77
|
|
|
78
78
|
proc.stdout.on('data', (d) => onLog(d.toString()));
|
|
79
79
|
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
@@ -96,7 +96,7 @@ function runNoRetry({
|
|
|
96
96
|
baseUrl,
|
|
97
97
|
startedAt,
|
|
98
98
|
onLog,
|
|
99
|
-
|
|
99
|
+
onRRwebBatch
|
|
100
100
|
}) {
|
|
101
101
|
runAttempt({
|
|
102
102
|
jobId,
|
|
@@ -108,7 +108,7 @@ function runNoRetry({
|
|
|
108
108
|
baseUrl,
|
|
109
109
|
suppressSave: false,
|
|
110
110
|
onLog,
|
|
111
|
-
|
|
111
|
+
onRRwebBatch
|
|
112
112
|
}).then(async ({ code }) => {
|
|
113
113
|
let reportId = null;
|
|
114
114
|
try {
|
|
@@ -142,7 +142,7 @@ function runWithRetriesAndSave({
|
|
|
142
142
|
maxRetries,
|
|
143
143
|
startedAt,
|
|
144
144
|
onLog,
|
|
145
|
-
|
|
145
|
+
onRRwebBatch
|
|
146
146
|
}) {
|
|
147
147
|
runWithRetries({
|
|
148
148
|
maxRetries,
|
|
@@ -157,7 +157,7 @@ function runWithRetriesAndSave({
|
|
|
157
157
|
baseUrl,
|
|
158
158
|
suppressSave: true,
|
|
159
159
|
onLog,
|
|
160
|
-
|
|
160
|
+
onRRwebBatch
|
|
161
161
|
});
|
|
162
162
|
return { code, rawJson: raw ? JSON.parse(raw) : [] };
|
|
163
163
|
},
|
|
@@ -169,6 +169,7 @@ function runWithRetriesAndSave({
|
|
|
169
169
|
rawCucumberJson: rawJson,
|
|
170
170
|
tags: tag,
|
|
171
171
|
triggerType: trigger,
|
|
172
|
+
workerCount: workers,
|
|
172
173
|
browser,
|
|
173
174
|
testRunId: testRunId ?? null,
|
|
174
175
|
duration: Date.now() - startedAt,
|
|
@@ -191,8 +192,8 @@ function runWithRetriesAndSave({
|
|
|
191
192
|
|
|
192
193
|
// Starts a background test run for the HTTP trigger API and returns the jobId
|
|
193
194
|
// immediately — the run continues asynchronously, reporting progress via
|
|
194
|
-
// socket.io events (bg-run-start/log/
|
|
195
|
-
//
|
|
195
|
+
// socket.io events (bg-run-start/log/done) and the job store (polled through
|
|
196
|
+
// getJob).
|
|
196
197
|
async function startRun({
|
|
197
198
|
tag = '',
|
|
198
199
|
browser = DEFAULT_BROWSER,
|
|
@@ -218,8 +219,14 @@ async function startRun({
|
|
|
218
219
|
const onLog = (text) => {
|
|
219
220
|
if (_io) _io.emit(SOCKET_EVENTS.BG_RUN_LOG, { runId: jobId, log: text });
|
|
220
221
|
};
|
|
221
|
-
const
|
|
222
|
-
if (_io)
|
|
222
|
+
const onRRwebBatch = (batch) => {
|
|
223
|
+
if (_io) {
|
|
224
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
225
|
+
runId: jobId,
|
|
226
|
+
id: BUILT_IN_RUNNER_ID,
|
|
227
|
+
...batch
|
|
228
|
+
});
|
|
229
|
+
}
|
|
223
230
|
};
|
|
224
231
|
|
|
225
232
|
const runParams = {
|
|
@@ -232,7 +239,7 @@ async function startRun({
|
|
|
232
239
|
baseUrl,
|
|
233
240
|
startedAt,
|
|
234
241
|
onLog,
|
|
235
|
-
|
|
242
|
+
onRRwebBatch
|
|
236
243
|
};
|
|
237
244
|
if (maxRetries === 0) {
|
|
238
245
|
runNoRetry(runParams);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
* Licensed under the MIT License. See LICENSE file in the project root for details.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const runnerService = require('../services/runnerService');
|
|
7
|
+
const nodeStreamRegistry = require('../services/nodeStreamRegistry');
|
|
8
|
+
|
|
9
|
+
// A separate namespace from the browser-facing default one — a node connects
|
|
10
|
+
// with its own runner token (the same credential it already uses to call back
|
|
11
|
+
// into the primary's HTTP control routes, see runnerService.isValidToken) and
|
|
12
|
+
// announces the jobId it's streaming for, which nodeStreamRegistry maps back
|
|
13
|
+
// to whichever dispatch call is waiting on it.
|
|
14
|
+
function nodeSocketHandler(io) {
|
|
15
|
+
const nodeIo = io.of('/node-stream');
|
|
16
|
+
|
|
17
|
+
nodeIo.use(async (socket, next) => {
|
|
18
|
+
const token = socket.handshake.auth?.token;
|
|
19
|
+
if (await runnerService.isValidToken(token)) return next();
|
|
20
|
+
next(new Error('unauthorized'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
nodeIo.on('connection', (socket) => {
|
|
24
|
+
let jobId = null;
|
|
25
|
+
|
|
26
|
+
socket.on('join', (id) => {
|
|
27
|
+
jobId = id;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
socket.on('rrweb-batch', (batch) => {
|
|
31
|
+
if (jobId) nodeStreamRegistry.getRelay(jobId)?.onRRwebBatch?.(batch);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
socket.on('log', (text) => {
|
|
35
|
+
if (jobId) nodeStreamRegistry.getRelay(jobId)?.onLog?.(text);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = nodeSocketHandler;
|