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
|
@@ -14,7 +14,8 @@ const { requireAdmin } = require('../middleware/requireAdmin');
|
|
|
14
14
|
|
|
15
15
|
router.get('/export', jwtAuth, requireAdmin, async (req, res) => {
|
|
16
16
|
try {
|
|
17
|
-
const
|
|
17
|
+
const { backupIncludeReports } = await settingsService.getBackupConfig();
|
|
18
|
+
const data = await backupService.exportAll(backupIncludeReports);
|
|
18
19
|
const fileName = `plum-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
19
20
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
20
21
|
res.setHeader('Content-Type', 'application/json');
|
|
@@ -87,6 +88,51 @@ router.post('/test-s3', jwtAuth, requireAdmin, async (req, res) => {
|
|
|
87
88
|
}
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
router.get('/s3-backups', jwtAuth, requireAdmin, async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const config = await settingsService.getProjectRaw();
|
|
94
|
+
const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
|
|
95
|
+
const missing = required.filter((k) => !config[k]);
|
|
96
|
+
if (missing.length > 0) {
|
|
97
|
+
return res
|
|
98
|
+
.status(400)
|
|
99
|
+
.json({ error: `S3 is not configured (missing: ${missing.join(', ')})` });
|
|
100
|
+
}
|
|
101
|
+
const backups = await backupService.listS3Backups(config);
|
|
102
|
+
res.json({ backups });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Failed to list S3 backups:', error);
|
|
105
|
+
res.status(500).json({ error: error.message || 'Failed to list S3 backups' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
router.post('/s3-restore', jwtAuth, requireAdmin, async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const { key } = req.body;
|
|
112
|
+
if (!key) return res.status(400).json({ error: 'Missing backup key' });
|
|
113
|
+
|
|
114
|
+
const config = await settingsService.getProjectRaw();
|
|
115
|
+
const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
|
|
116
|
+
const missing = required.filter((k) => !config[k]);
|
|
117
|
+
if (missing.length > 0) {
|
|
118
|
+
return res
|
|
119
|
+
.status(400)
|
|
120
|
+
.json({ error: `S3 is not configured (missing: ${missing.join(', ')})` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await backupService.downloadFromS3(key, config);
|
|
124
|
+
const { cronJobs, project, users, runners, testSuites, testRuns } = data;
|
|
125
|
+
await backupService.importAll(
|
|
126
|
+
{ cronJobs, project, users, runners, testSuites, testRuns },
|
|
127
|
+
cronService
|
|
128
|
+
);
|
|
129
|
+
res.json({ message: 'Restore successful' });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('S3 restore failed:', error);
|
|
132
|
+
res.status(500).json({ error: error.message || 'Restore failed' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
90
136
|
router.post('/run-now', jwtAuth, requireAdmin, async (req, res) => {
|
|
91
137
|
try {
|
|
92
138
|
await backupCronService.runBackup();
|
|
@@ -27,6 +27,29 @@ router.get('/latest', async (req, res) => {
|
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
router.get('/:id/recordings', async (req, res) => {
|
|
31
|
+
const id = parseInt(req.params.id, 10);
|
|
32
|
+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
|
33
|
+
try {
|
|
34
|
+
const recordings = await reportService.getRecordings(id);
|
|
35
|
+
res.json(recordings);
|
|
36
|
+
} catch {
|
|
37
|
+
res.status(500).json({ error: 'Failed to fetch recordings' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
router.get('/:id/recordings/:recordingId/events', async (req, res) => {
|
|
42
|
+
const recordingId = parseInt(req.params.recordingId, 10);
|
|
43
|
+
if (isNaN(recordingId)) return res.status(400).json({ error: 'Invalid recording id' });
|
|
44
|
+
try {
|
|
45
|
+
const events = await reportService.getRecordingEvents(recordingId);
|
|
46
|
+
if (!events) return res.status(404).json({ error: 'Recording not found' });
|
|
47
|
+
res.json({ events });
|
|
48
|
+
} catch {
|
|
49
|
+
res.status(500).json({ error: 'Failed to fetch recording events' });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
30
53
|
router.get('/:id', async (req, res) => {
|
|
31
54
|
const id = parseInt(req.params.id, 10);
|
|
32
55
|
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
package/backend/server.js
CHANGED
|
@@ -30,7 +30,7 @@ const port = parseInt(process.env.PORT || DEFAULT_PORT, 10);
|
|
|
30
30
|
// The underlying HTTP server, shared by Express and Socket.io.
|
|
31
31
|
const server = http.createServer(app);
|
|
32
32
|
|
|
33
|
-
// Real-time transport for live test output,
|
|
33
|
+
// Real-time transport for live test output, the rrweb stream, and run status.
|
|
34
34
|
const io = new Server(server, { cors: { origin: '*' } });
|
|
35
35
|
|
|
36
36
|
async function start() {
|
|
@@ -21,7 +21,7 @@ const runBackup = async () => {
|
|
|
21
21
|
if (!project?.backupEnabled) return;
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
|
-
const data = await backupService.exportAll();
|
|
24
|
+
const data = await backupService.exportAll(project.backupIncludeReports);
|
|
25
25
|
const key = await backupService.uploadToS3(data, project);
|
|
26
26
|
|
|
27
27
|
await prisma.project.update({
|
|
@@ -8,11 +8,33 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
|
|
|
8
8
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Export
|
|
11
|
+
// Export
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Reports (with their rrweb recordings) are opt-in — they can be large, and
|
|
15
|
+
// this used to be a hard no ("reports are too large, use pg_dump") back when
|
|
16
|
+
// screenshots lived as external files on disk. Now everything lives in
|
|
17
|
+
// Postgres, so it's just a size tradeoff the admin can choose. Recording.events
|
|
18
|
+
// is gzip-compressed BYTEA — base64 it for JSON transport; startedAt/endedAt
|
|
19
|
+
// are BigInt, which JSON.stringify can't serialize natively.
|
|
20
|
+
async function exportReports() {
|
|
21
|
+
const reports = await prisma.report.findMany({
|
|
22
|
+
orderBy: { createdAt: 'asc' },
|
|
23
|
+
include: { recordings: true }
|
|
24
|
+
});
|
|
25
|
+
return reports.map(({ recordings, ...report }) => ({
|
|
26
|
+
...report,
|
|
27
|
+
recordings: recordings.map(({ events, startedAt, endedAt, ...rec }) => ({
|
|
28
|
+
...rec,
|
|
29
|
+
events: events.toString('base64'),
|
|
30
|
+
startedAt: startedAt?.toString() ?? null,
|
|
31
|
+
endedAt: endedAt?.toString() ?? null
|
|
32
|
+
}))
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const exportAll = async (includeReports = false) => {
|
|
37
|
+
const [cronJobs, project, testSuites, testRuns, users, runners, reports] = await Promise.all([
|
|
16
38
|
prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
17
39
|
prisma.project.findUnique({ where: { id: 1 } }),
|
|
18
40
|
prisma.testSuite.findMany({
|
|
@@ -29,14 +51,16 @@ const exportAll = async () => {
|
|
|
29
51
|
include: { entries: { orderBy: { order: 'asc' } } }
|
|
30
52
|
}),
|
|
31
53
|
prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
32
|
-
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
|
|
54
|
+
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
55
|
+
includeReports ? exportReports() : Promise.resolve(null)
|
|
33
56
|
]);
|
|
34
57
|
|
|
35
58
|
return {
|
|
36
|
-
version: '
|
|
59
|
+
version: '3',
|
|
37
60
|
exportedAt: new Date().toISOString(),
|
|
38
|
-
disclaimer:
|
|
39
|
-
'Reports are
|
|
61
|
+
disclaimer: includeReports
|
|
62
|
+
? 'Reports and recordings are included in this backup.'
|
|
63
|
+
: 'Reports are not included in this backup. Enable "Include reports" in Settings → Backup, or use pg_dump on the PostgreSQL volume, to back up report history.',
|
|
40
64
|
cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
|
|
41
65
|
project: project
|
|
42
66
|
? {
|
|
@@ -62,7 +86,8 @@ const exportAll = async () => {
|
|
|
62
86
|
testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
|
|
63
87
|
...run,
|
|
64
88
|
entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
|
|
65
|
-
}))
|
|
89
|
+
})),
|
|
90
|
+
...(reports !== null && { reports })
|
|
66
91
|
};
|
|
67
92
|
};
|
|
68
93
|
|
|
@@ -71,7 +96,15 @@ const exportAll = async () => {
|
|
|
71
96
|
// ---------------------------------------------------------------------------
|
|
72
97
|
|
|
73
98
|
const importAll = async (
|
|
74
|
-
{
|
|
99
|
+
{
|
|
100
|
+
cronJobs = [],
|
|
101
|
+
project = null,
|
|
102
|
+
users = [],
|
|
103
|
+
runners = [],
|
|
104
|
+
testSuites = [],
|
|
105
|
+
testRuns = [],
|
|
106
|
+
reports = []
|
|
107
|
+
},
|
|
75
108
|
cronService
|
|
76
109
|
) => {
|
|
77
110
|
await prisma.$transaction(
|
|
@@ -186,8 +219,41 @@ const importAll = async (
|
|
|
186
219
|
});
|
|
187
220
|
}
|
|
188
221
|
}
|
|
222
|
+
|
|
223
|
+
// 7. Reports + recordings (opt-in — only present if this backup
|
|
224
|
+
// included them). Recordings are always deleted and recreated
|
|
225
|
+
// rather than upserted — same pattern as test steps above.
|
|
226
|
+
for (const report of reports) {
|
|
227
|
+
const { recordings = [], cronJobId: _staleCronJobId, ...reportData } = report;
|
|
228
|
+
|
|
229
|
+
// cronJobId can't be trusted as exported — cron jobs above are
|
|
230
|
+
// upserted keyed on taskName, not id, so the id a report recorded
|
|
231
|
+
// at export time may no longer point at the right row (or any
|
|
232
|
+
// row). Re-resolve it the same way reportService does when a
|
|
233
|
+
// report is first created: a scheduled report's triggerType is
|
|
234
|
+
// always its cron job's taskName.
|
|
235
|
+
const cronJob = reportData.triggerType
|
|
236
|
+
? await tx.cronJob.findUnique({ where: { taskName: reportData.triggerType } })
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
const data = { ...reportData, cronJobId: cronJob?.id ?? null };
|
|
240
|
+
await tx.report.upsert({ where: { id: data.id }, create: data, update: data });
|
|
241
|
+
|
|
242
|
+
await tx.recording.deleteMany({ where: { reportId: data.id } });
|
|
243
|
+
for (const rec of recordings) {
|
|
244
|
+
await tx.recording.create({
|
|
245
|
+
data: {
|
|
246
|
+
...rec,
|
|
247
|
+
reportId: data.id,
|
|
248
|
+
events: Buffer.from(rec.events, 'base64'),
|
|
249
|
+
startedAt: rec.startedAt !== null ? BigInt(rec.startedAt) : null,
|
|
250
|
+
endedAt: rec.endedAt !== null ? BigInt(rec.endedAt) : null
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
189
255
|
},
|
|
190
|
-
{ timeout:
|
|
256
|
+
{ timeout: 60000 }
|
|
191
257
|
);
|
|
192
258
|
|
|
193
259
|
if (cronService) await cronService.reload();
|
|
@@ -197,18 +263,16 @@ const importAll = async (
|
|
|
197
263
|
// S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
|
|
198
264
|
// ---------------------------------------------------------------------------
|
|
199
265
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
} = config;
|
|
211
|
-
|
|
266
|
+
// A custom endpoint means R2/B2/MinIO/on-prem, not real AWS S3 — those
|
|
267
|
+
// virtually always need path-style addressing (bucket.endpoint/... resolves
|
|
268
|
+
// nowhere for a self-hosted host like a docker service name). Real AWS S3
|
|
269
|
+
// (no custom endpoint) keeps the SDK's virtual-hosted-style default.
|
|
270
|
+
function buildS3ClientConfig({
|
|
271
|
+
backupS3Endpoint,
|
|
272
|
+
backupS3Region,
|
|
273
|
+
backupS3AccessKey,
|
|
274
|
+
backupS3SecretKey
|
|
275
|
+
}) {
|
|
212
276
|
const clientConfig = {
|
|
213
277
|
region: backupS3Region || 'auto',
|
|
214
278
|
credentials: {
|
|
@@ -216,9 +280,17 @@ const uploadToS3 = async (jsonData, config) => {
|
|
|
216
280
|
secretAccessKey: backupS3SecretKey
|
|
217
281
|
}
|
|
218
282
|
};
|
|
219
|
-
if (backupS3Endpoint)
|
|
283
|
+
if (backupS3Endpoint) {
|
|
284
|
+
clientConfig.endpoint = backupS3Endpoint;
|
|
285
|
+
clientConfig.forcePathStyle = true;
|
|
286
|
+
}
|
|
287
|
+
return clientConfig;
|
|
288
|
+
}
|
|
220
289
|
|
|
221
|
-
|
|
290
|
+
const uploadToS3 = async (jsonData, config) => {
|
|
291
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
292
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
293
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
222
294
|
|
|
223
295
|
const date = new Date().toISOString().slice(0, 10);
|
|
224
296
|
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
@@ -236,28 +308,39 @@ const uploadToS3 = async (jsonData, config) => {
|
|
|
236
308
|
return key;
|
|
237
309
|
};
|
|
238
310
|
|
|
239
|
-
|
|
240
|
-
|
|
311
|
+
// Lists backups previously uploaded by uploadToS3, newest first — the data
|
|
312
|
+
// needed for a "restore from S3" flow that doesn't require the admin to pull
|
|
313
|
+
// the file down through the S3 console/CLI themselves first.
|
|
314
|
+
const listS3Backups = async (config) => {
|
|
315
|
+
const { S3Client, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
316
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
317
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
318
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
241
319
|
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
backupS3Bucket,
|
|
246
|
-
backupS3AccessKey,
|
|
247
|
-
backupS3SecretKey,
|
|
248
|
-
backupS3Prefix
|
|
249
|
-
} = config;
|
|
320
|
+
const res = await client.send(
|
|
321
|
+
new ListObjectsV2Command({ Bucket: backupS3Bucket, Prefix: prefix })
|
|
322
|
+
);
|
|
250
323
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
324
|
+
return (res.Contents ?? [])
|
|
325
|
+
.filter((o) => o.Key.endsWith('.json'))
|
|
326
|
+
.map((o) => ({ key: o.Key, size: o.Size, lastModified: o.LastModified }))
|
|
327
|
+
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Fetches and parses one backup previously uploaded by uploadToS3.
|
|
331
|
+
const downloadFromS3 = async (key, config) => {
|
|
332
|
+
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
333
|
+
const { backupS3Bucket } = config;
|
|
334
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
335
|
+
const res = await client.send(new GetObjectCommand({ Bucket: backupS3Bucket, Key: key }));
|
|
336
|
+
const text = await res.Body.transformToString('utf8');
|
|
337
|
+
return JSON.parse(text);
|
|
338
|
+
};
|
|
259
339
|
|
|
260
|
-
|
|
340
|
+
const testS3Connection = async (config) => {
|
|
341
|
+
const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
342
|
+
const { backupS3Bucket, backupS3Prefix } = config;
|
|
343
|
+
const client = new S3Client(buildS3ClientConfig(config));
|
|
261
344
|
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
262
345
|
const key = `${prefix}.plum-connection-test`;
|
|
263
346
|
|
|
@@ -276,4 +359,11 @@ const testS3Connection = async (config) => {
|
|
|
276
359
|
} catch {}
|
|
277
360
|
};
|
|
278
361
|
|
|
279
|
-
module.exports = {
|
|
362
|
+
module.exports = {
|
|
363
|
+
exportAll,
|
|
364
|
+
importAll,
|
|
365
|
+
uploadToS3,
|
|
366
|
+
listS3Backups,
|
|
367
|
+
downloadFromS3,
|
|
368
|
+
testS3Connection
|
|
369
|
+
};
|
|
@@ -14,7 +14,7 @@ const reportService = require('./reportService');
|
|
|
14
14
|
const settingsService = require('./settingsService');
|
|
15
15
|
const notificationService = require('./notificationService');
|
|
16
16
|
const activeRunsService = require('./activeRunsService');
|
|
17
|
-
const {
|
|
17
|
+
const { startRRwebPoller } = require('../lib/rrwebPoller');
|
|
18
18
|
const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE, TRIGGER_TYPE } = require('../constants/triggers');
|
|
19
19
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
20
20
|
const { PLUM_MODE_NODE } = require('../constants/env');
|
|
@@ -82,8 +82,14 @@ function runSingleBuiltInAttempt({ taskName, currentTag, workers, browser, suppr
|
|
|
82
82
|
|
|
83
83
|
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
84
84
|
|
|
85
|
-
const ssPoller =
|
|
86
|
-
if (_io)
|
|
85
|
+
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
86
|
+
if (_io) {
|
|
87
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
88
|
+
runId: taskName,
|
|
89
|
+
id: BUILT_IN_RUNNER_ID,
|
|
90
|
+
...batch
|
|
91
|
+
});
|
|
92
|
+
}
|
|
87
93
|
});
|
|
88
94
|
|
|
89
95
|
task.stdout.on('data', (d) => {
|
|
@@ -191,6 +197,7 @@ async function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscor
|
|
|
191
197
|
rawCucumberJson: rawJson,
|
|
192
198
|
tags,
|
|
193
199
|
triggerType: taskName,
|
|
200
|
+
workerCount: workers,
|
|
194
201
|
browser,
|
|
195
202
|
duration: Date.now() - startedAt,
|
|
196
203
|
attempts
|
|
@@ -282,6 +289,7 @@ async function runDistributed({
|
|
|
282
289
|
.saveCombinedReport({
|
|
283
290
|
reports: collectedReports,
|
|
284
291
|
runners: laneInfos,
|
|
292
|
+
workers,
|
|
285
293
|
overallCode,
|
|
286
294
|
tag: tags,
|
|
287
295
|
triggerType: taskName,
|
|
@@ -352,14 +360,14 @@ async function runDistributed({
|
|
|
352
360
|
|
|
353
361
|
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
354
362
|
|
|
355
|
-
const ssPoller =
|
|
356
|
-
if (_io)
|
|
357
|
-
_io.emit(SOCKET_EVENTS.
|
|
363
|
+
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
364
|
+
if (_io) {
|
|
365
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
358
366
|
runId: taskName,
|
|
359
|
-
laneId,
|
|
360
|
-
|
|
361
|
-
data
|
|
367
|
+
id: laneId,
|
|
368
|
+
...batch
|
|
362
369
|
});
|
|
370
|
+
}
|
|
363
371
|
});
|
|
364
372
|
|
|
365
373
|
task.stdout.on('data', (d) => {
|
|
@@ -399,14 +407,14 @@ async function runDistributed({
|
|
|
399
407
|
{ tags: currentTag, browser, workers },
|
|
400
408
|
onLog,
|
|
401
409
|
(code, content) => resolve({ code, rawJson: content ? JSON.parse(content) : [] }),
|
|
402
|
-
(
|
|
403
|
-
if (_io)
|
|
404
|
-
_io.emit(SOCKET_EVENTS.
|
|
410
|
+
(batch) => {
|
|
411
|
+
if (_io) {
|
|
412
|
+
_io.emit(SOCKET_EVENTS.BG_RUN_LANE_RRWEB_BATCH, {
|
|
405
413
|
runId: taskName,
|
|
406
|
-
laneId,
|
|
407
|
-
|
|
408
|
-
data
|
|
414
|
+
id: laneId,
|
|
415
|
+
...batch
|
|
409
416
|
});
|
|
417
|
+
}
|
|
410
418
|
}
|
|
411
419
|
);
|
|
412
420
|
});
|
|
@@ -8,7 +8,8 @@ const os = require('os');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
|
-
const {
|
|
11
|
+
const { io: ioClient } = require('socket.io-client');
|
|
12
|
+
const { startRRwebPoller } = require('../lib/rrwebPoller');
|
|
12
13
|
const { TRIGGER_REMOTE } = require('../constants/triggers');
|
|
13
14
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
14
15
|
const { JOB_STATUS } = require('../constants/jobStatus');
|
|
@@ -23,17 +24,40 @@ function getJob(jobId) {
|
|
|
23
24
|
return jobs[jobId];
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// Opens this node's own outbound connection back to the primary for this job
|
|
28
|
+
// — logs/rrweb events are pushed the moment they happen instead of waiting to
|
|
29
|
+
// be polled. Uses the same token this node already validates incoming HTTP
|
|
30
|
+
// calls against (see authGuard), so there's no separate credential to manage.
|
|
31
|
+
// Best-effort: if the primary is unreachable this way, the job still runs —
|
|
32
|
+
// dispatchAndPoll falls back to draining logs from the HTTP poll when it
|
|
33
|
+
// never registered a socket relay for this jobId.
|
|
34
|
+
function connectPrimaryStream(primaryUrl, jobId) {
|
|
35
|
+
try {
|
|
36
|
+
const socket = ioClient(`${primaryUrl}/node-stream`, {
|
|
37
|
+
auth: { token: process.env.NODE_TOKEN },
|
|
38
|
+
reconnectionAttempts: 5,
|
|
39
|
+
timeout: 8000
|
|
40
|
+
});
|
|
41
|
+
socket.on('connect', () => socket.emit('join', jobId));
|
|
42
|
+
return socket;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// Starts a remote test job dispatched from the primary server: materializes
|
|
27
|
-
// any uploaded test files, spawns `npm run test`, and tracks logs
|
|
28
|
-
//
|
|
49
|
+
// any uploaded test files, spawns `npm run test`, and tracks logs for later
|
|
50
|
+
// HTTP polling (see pollJob).
|
|
29
51
|
function startJob({
|
|
30
52
|
tags,
|
|
31
53
|
browser = DEFAULT_BROWSER,
|
|
32
54
|
workers = 1,
|
|
33
55
|
tests = null,
|
|
34
|
-
env: userEnv = {}
|
|
56
|
+
env: userEnv = {},
|
|
57
|
+
primaryUrl = null
|
|
35
58
|
}) {
|
|
36
59
|
const jobId = crypto.randomUUID();
|
|
60
|
+
const primaryStream = primaryUrl ? connectPrimaryStream(primaryUrl, jobId) : null;
|
|
37
61
|
|
|
38
62
|
// path.resolve ensures absolute even if TMPDIR env var is set to a relative path
|
|
39
63
|
const tmpdir = path.resolve(os.tmpdir());
|
|
@@ -63,8 +87,7 @@ function startJob({
|
|
|
63
87
|
meta: { tags: tags || '', browser, workers },
|
|
64
88
|
tempTestsDir,
|
|
65
89
|
reportFile,
|
|
66
|
-
ssDir
|
|
67
|
-
pendingScreenshots: []
|
|
90
|
+
ssDir
|
|
68
91
|
};
|
|
69
92
|
|
|
70
93
|
const env = {
|
|
@@ -84,19 +107,24 @@ function startJob({
|
|
|
84
107
|
};
|
|
85
108
|
if (workers > 1) env.PARALLEL = String(workers);
|
|
86
109
|
|
|
87
|
-
const ssPoller =
|
|
88
|
-
|
|
110
|
+
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
111
|
+
primaryStream?.emit('rrweb-batch', batch);
|
|
89
112
|
});
|
|
90
113
|
|
|
91
114
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
|
|
92
115
|
proc.stdout.on('data', (d) => {
|
|
93
|
-
|
|
116
|
+
const text = d.toString();
|
|
117
|
+
jobs[jobId].logs += text;
|
|
118
|
+
primaryStream?.emit('log', text);
|
|
94
119
|
});
|
|
95
120
|
proc.stderr.on('data', (d) => {
|
|
96
|
-
|
|
121
|
+
const text = d.toString();
|
|
122
|
+
jobs[jobId].logs += text;
|
|
123
|
+
primaryStream?.emit('log', text);
|
|
97
124
|
});
|
|
98
125
|
proc.on('close', (code) => {
|
|
99
126
|
clearInterval(ssPoller);
|
|
127
|
+
primaryStream?.close();
|
|
100
128
|
jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
|
|
101
129
|
jobs[jobId].exitCode = code;
|
|
102
130
|
|
|
@@ -123,17 +151,15 @@ function startJob({
|
|
|
123
151
|
return jobId;
|
|
124
152
|
}
|
|
125
153
|
|
|
126
|
-
// Drains and returns
|
|
127
|
-
//
|
|
154
|
+
// Drains and returns logs since `offset` — used by the primary's HTTP polling
|
|
155
|
+
// loop (this node has no socket.io connection back).
|
|
128
156
|
function pollJob(jobId, offset) {
|
|
129
157
|
const job = jobs[jobId];
|
|
130
158
|
if (!job) return null;
|
|
131
|
-
const screenshots = job.pendingScreenshots.splice(0);
|
|
132
159
|
return {
|
|
133
160
|
status: job.status,
|
|
134
161
|
logs: job.logs.slice(offset),
|
|
135
|
-
exitCode: job.exitCode
|
|
136
|
-
screenshots
|
|
162
|
+
exitCode: job.exitCode
|
|
137
163
|
};
|
|
138
164
|
}
|
|
139
165
|
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
// In-memory jobId -> relay callbacks, so an incoming node socket (which only
|
|
7
|
+
// knows its own jobId) can find where to forward live rrweb/log events —
|
|
8
|
+
// registered by whichever dispatch call (socketHandler, cronService) started
|
|
9
|
+
// this job and already knows the browser-facing emit target/laneId.
|
|
10
|
+
const relays = new Map();
|
|
11
|
+
|
|
12
|
+
function registerRelay(jobId, { onRRwebBatch, onLog }) {
|
|
13
|
+
relays.set(jobId, { onRRwebBatch, onLog });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function unregisterRelay(jobId) {
|
|
17
|
+
relays.delete(jobId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getRelay(jobId) {
|
|
21
|
+
return relays.get(jobId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { registerRelay, unregisterRelay, getRelay };
|