plum-e2e 2.8.5 → 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/routes/test-cases.routes.js +7 -2
- 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 +174 -84
- package/backend/services/runnerService.js +19 -7
- package/backend/services/settingsService.js +6 -3
- package/backend/services/testCaseService.js +3 -2
- package/backend/services/triggerService.js +20 -13
- package/backend/services/userService.js +1 -3
- package/backend/websockets/nodeSocketHandler.js +40 -0
- package/backend/websockets/socketHandler.js +9 -7
- package/frontend/.svelte-kit/adapter-node/.vite/manifest.json +1 -1
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.C1TbMUws.css +6 -0
- package/frontend/.svelte-kit/adapter-node/chunks/internal.js +1 -1
- package/frontend/.svelte-kit/adapter-node/entries/pages/_page.svelte.js +22 -22
- package/frontend/.svelte-kit/adapter-node/entries/pages/settings/_page.svelte.js +3 -3
- package/frontend/.svelte-kit/adapter-node/manifest-full.js +1 -1
- package/frontend/.svelte-kit/adapter-node/manifest.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/0.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/1.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/11.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/12.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/2.js +2 -2
- package/frontend/.svelte-kit/adapter-node/nodes/3.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/5.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/6.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/8.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/9.js +1 -1
- package/frontend/.svelte-kit/ambient.d.ts +4 -0
- package/frontend/.svelte-kit/generated/server/internal.js +1 -1
- package/frontend/.svelte-kit/output/client/.vite/manifest.json +35 -35
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.C1TbMUws.css +6 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.C1TbMUws.css +6 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{DEKAA-Fm.js → DDZw0L0T.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{Dhf4xQKo.js → kkA1mrsH.js} +2 -2
- package/frontend/.svelte-kit/output/client/_app/immutable/entry/{app.B0GH5ONo.js → app.KrtMZ3my.js} +2 -2
- package/frontend/.svelte-kit/output/client/_app/immutable/entry/{start.Bn25CW9X.js → start.4qxHHAcl.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{0.-imXt0f5.js → 0.C8DodwXs.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/1.Dom3CKWF.js → .svelte-kit/output/client/_app/immutable/nodes/1.Df9sLw8b.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/11.DMlayLCK.js → .svelte-kit/output/client/_app/immutable/nodes/11.BViaDK2m.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/12.Bu89A1eX.js → .svelte-kit/output/client/_app/immutable/nodes/12.BMSCSd88.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/2.BCPSg7H4.js +6 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{3.B86rlHXU.js → 3.8BFAJoHT.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/5.CqTXvadV.js → .svelte-kit/output/client/_app/immutable/nodes/5.DVeH2eQP.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{6.BJZ62MCd.js → 6.DI7-IJhN.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/8.B4fj2Xyu.js → .svelte-kit/output/client/_app/immutable/nodes/8.93Mb3ofT.js} +4 -4
- package/frontend/{build/client/_app/immutable/nodes/9.C57_2xq6.js → .svelte-kit/output/client/_app/immutable/nodes/9.CvfGx3Hu.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/version.json +1 -1
- package/frontend/.svelte-kit/output/server/.vite/manifest.json +1 -1
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.C1TbMUws.css +6 -0
- package/frontend/.svelte-kit/output/server/chunks/internal.js +1 -1
- package/frontend/.svelte-kit/output/server/entries/pages/_page.svelte.js +22 -22
- package/frontend/.svelte-kit/output/server/entries/pages/settings/_page.svelte.js +3 -3
- package/frontend/.svelte-kit/output/server/manifest-full.js +1 -1
- package/frontend/.svelte-kit/output/server/manifest.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/0.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/1.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/11.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/12.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/2.js +2 -2
- package/frontend/.svelte-kit/output/server/nodes/3.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/5.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/6.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/8.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/9.js +1 -1
- package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css +6 -0
- package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css.br +0 -0
- package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css.gz +0 -0
- package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css +6 -0
- package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css.br +0 -0
- package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/{DEKAA-Fm.js → DDZw0L0T.js} +1 -1
- package/frontend/build/client/_app/immutable/chunks/DDZw0L0T.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/DDZw0L0T.js.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/{Dhf4xQKo.js → kkA1mrsH.js} +2 -2
- package/frontend/build/client/_app/immutable/chunks/kkA1mrsH.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/kkA1mrsH.js.gz +0 -0
- package/frontend/build/client/_app/immutable/entry/{app.B0GH5ONo.js → app.KrtMZ3my.js} +2 -2
- package/frontend/build/client/_app/immutable/entry/app.KrtMZ3my.js.br +0 -0
- package/frontend/build/client/_app/immutable/entry/app.KrtMZ3my.js.gz +0 -0
- package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js → start.4qxHHAcl.js} +1 -1
- package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js.br → start.4qxHHAcl.js.br} +0 -0
- package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js.gz → start.4qxHHAcl.js.gz} +0 -0
- package/frontend/build/client/_app/immutable/nodes/{0.-imXt0f5.js → 0.C8DodwXs.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/0.C8DodwXs.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.C8DodwXs.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/1.Dom3CKWF.js → build/client/_app/immutable/nodes/1.Df9sLw8b.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/1.Df9sLw8b.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.Df9sLw8b.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/11.DMlayLCK.js → build/client/_app/immutable/nodes/11.BViaDK2m.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/11.BViaDK2m.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.BViaDK2m.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/12.Bu89A1eX.js → build/client/_app/immutable/nodes/12.BMSCSd88.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/12.BMSCSd88.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/{12.Bu89A1eX.js.gz → 12.BMSCSd88.js.gz} +0 -0
- package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js +6 -0
- package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{3.B86rlHXU.js → 3.8BFAJoHT.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/3.8BFAJoHT.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/3.8BFAJoHT.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/5.CqTXvadV.js → build/client/_app/immutable/nodes/5.DVeH2eQP.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/5.DVeH2eQP.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.DVeH2eQP.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{6.BJZ62MCd.js → 6.DI7-IJhN.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/6.DI7-IJhN.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.DI7-IJhN.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/8.B4fj2Xyu.js → build/client/_app/immutable/nodes/8.93Mb3ofT.js} +4 -4
- package/frontend/build/client/_app/immutable/nodes/8.93Mb3ofT.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.93Mb3ofT.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/9.C57_2xq6.js → build/client/_app/immutable/nodes/9.CvfGx3Hu.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/9.CvfGx3Hu.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.CvfGx3Hu.js.gz +0 -0
- package/frontend/build/client/_app/version.json +1 -1
- package/frontend/build/client/_app/version.json.br +0 -0
- package/frontend/build/client/_app/version.json.gz +0 -0
- package/frontend/build/server/chunks/{0-DG1Uv07a.js → 0-D5YWFygh.js} +2 -2
- package/frontend/build/server/chunks/{0-DG1Uv07a.js.map → 0-D5YWFygh.js.map} +1 -1
- package/frontend/build/server/chunks/{1-iB0ogJU_.js → 1-Kxn9d30Z.js} +2 -2
- package/frontend/build/server/chunks/{1-iB0ogJU_.js.map → 1-Kxn9d30Z.js.map} +1 -1
- package/frontend/build/server/chunks/{11-Df9vGZ_v.js → 11-CNLerze_.js} +2 -2
- package/frontend/build/server/chunks/{11-Df9vGZ_v.js.map → 11-CNLerze_.js.map} +1 -1
- package/frontend/build/server/chunks/{12-DqvjCln4.js → 12-De6Ol6pu.js} +2 -2
- package/frontend/build/server/chunks/{12-DqvjCln4.js.map → 12-De6Ol6pu.js.map} +1 -1
- package/frontend/build/server/chunks/{2-Cte30Dgy.js → 2-Dc_rKWoK.js} +4 -4
- package/frontend/build/server/chunks/{2-Cte30Dgy.js.map → 2-Dc_rKWoK.js.map} +1 -1
- package/frontend/build/server/chunks/{3-BYKCZi4U.js → 3-DrRqVrcX.js} +2 -2
- package/frontend/build/server/chunks/{3-BYKCZi4U.js.map → 3-DrRqVrcX.js.map} +1 -1
- package/frontend/build/server/chunks/{5-B6Jc1foX.js → 5-CFeE5dPZ.js} +2 -2
- package/frontend/build/server/chunks/{5-B6Jc1foX.js.map → 5-CFeE5dPZ.js.map} +1 -1
- package/frontend/build/server/chunks/{6-dXZKtN86.js → 6-DZk37MZH.js} +2 -2
- package/frontend/build/server/chunks/{6-dXZKtN86.js.map → 6-DZk37MZH.js.map} +1 -1
- package/frontend/build/server/chunks/{8-D-Bp7HzS.js → 8-CKPVFOqJ.js} +3 -3
- package/frontend/build/server/chunks/{8-D-Bp7HzS.js.map → 8-CKPVFOqJ.js.map} +1 -1
- package/frontend/build/server/chunks/{9-BynjuGUO.js → 9-DirZG_Lu.js} +2 -2
- package/frontend/build/server/chunks/{9-BynjuGUO.js.map → 9-DirZG_Lu.js.map} +1 -1
- package/frontend/build/server/chunks/{_page.svelte-CpB-fgJl.js → _page.svelte-04WxjOuH.js} +23 -23
- package/frontend/build/server/chunks/_page.svelte-04WxjOuH.js.map +5 -0
- package/frontend/build/server/chunks/{_page.svelte-wKyUmA2e.js → _page.svelte-8gELc_B1.js} +4 -4
- package/frontend/build/server/chunks/_page.svelte-8gELc_B1.js.map +5 -0
- package/frontend/build/server/index.js +1 -1
- package/frontend/build/server/index.js.map +1 -1
- package/frontend/build/server/manifest.js +11 -11
- package/frontend/build/server/manifest.js.map +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/repository.js +2 -2
- 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/repository.js +6 -0
- 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/+page.svelte +6 -1
- 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/frontend/src/routes/test-repository/suites/[id]/+page.svelte +116 -0
- package/package.json +1 -1
- package/backend/playwright.config.js +0 -85
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.DvMkmYA-.css +0 -6
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/2.BnHalSCi.js +0 -6
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
- package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css +0 -6
- package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css.br +0 -0
- package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css.gz +0 -0
- package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
- package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css.br +0 -0
- package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.gz +0 -0
- package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.br +0 -0
- package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/12.Bu89A1eX.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js +0 -6
- package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.gz +0 -0
- package/frontend/build/server/chunks/_page.svelte-CpB-fgJl.js.map +0 -5
- package/frontend/build/server/chunks/_page.svelte-wKyUmA2e.js.map +0 -5
|
@@ -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,68 +207,74 @@ 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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
}
|
|
263
|
+
|
|
264
|
+
const steps = visibleSteps.map((step) => {
|
|
265
|
+
const rawStatus = step.result?.status ?? 'pending';
|
|
266
|
+
// Undefined/ambiguous steps rank below 'failed' otherwise, so a step
|
|
267
|
+
// definition mismatch reports as passing instead of failing.
|
|
268
|
+
const status =
|
|
269
|
+
rawStatus === 'undefined' || rawStatus === 'ambiguous' ? 'failed' : rawStatus;
|
|
244
270
|
|
|
245
271
|
return {
|
|
246
272
|
keyword: step.keyword.trim(),
|
|
247
273
|
name: step.name ?? '',
|
|
248
|
-
status
|
|
274
|
+
status,
|
|
249
275
|
duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
|
|
250
276
|
error: step.result?.error_message ?? null,
|
|
251
|
-
|
|
277
|
+
dataTable: step.arguments?.[0]?.rows?.map((row) => row.cells) ?? null
|
|
252
278
|
};
|
|
253
279
|
});
|
|
254
280
|
|
|
@@ -258,12 +284,15 @@ function processCucumberJson(raw, attempts = {}) {
|
|
|
258
284
|
}, 'passed');
|
|
259
285
|
|
|
260
286
|
return {
|
|
287
|
+
id: scenarioUniqueId(scenario),
|
|
261
288
|
name: scenario.name,
|
|
262
289
|
keyword: scenario.keyword,
|
|
263
290
|
tags: (scenario.tags ?? []).map((t) => t.name),
|
|
264
291
|
status: worstStatus,
|
|
265
292
|
duration: steps.reduce((s, st) => s + st.duration, 0),
|
|
266
293
|
attempts: attempts[scenarioIdTag(scenario)] ?? 1,
|
|
294
|
+
workerId: extractWorkerId(scenario),
|
|
295
|
+
runnerName: scenario.__plumRunnerName ?? null,
|
|
267
296
|
steps
|
|
268
297
|
};
|
|
269
298
|
});
|
|
@@ -277,7 +306,7 @@ function processCucumberJson(raw, attempts = {}) {
|
|
|
277
306
|
});
|
|
278
307
|
|
|
279
308
|
const hasFailures = features.some((f) => f.status === 'failed');
|
|
280
|
-
return { features, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
|
|
309
|
+
return { features, recordings, status: hasFailures ? REPORT_STATUS.FAIL : REPORT_STATUS.PASS };
|
|
281
310
|
}
|
|
282
311
|
|
|
283
312
|
// ---------------------------------------------------------------------------
|
|
@@ -289,7 +318,8 @@ const reportListSelect = {
|
|
|
289
318
|
status: true,
|
|
290
319
|
tags: true,
|
|
291
320
|
triggerType: true,
|
|
292
|
-
|
|
321
|
+
runnerCount: true,
|
|
322
|
+
workerCount: true,
|
|
293
323
|
browser: true,
|
|
294
324
|
runnerName: true,
|
|
295
325
|
createdAt: true,
|
|
@@ -334,7 +364,8 @@ const getReportDetail = async (id) => {
|
|
|
334
364
|
status: true,
|
|
335
365
|
tags: true,
|
|
336
366
|
triggerType: true,
|
|
337
|
-
|
|
367
|
+
runnerCount: true,
|
|
368
|
+
workerCount: true,
|
|
338
369
|
browser: true,
|
|
339
370
|
runnerName: true,
|
|
340
371
|
createdAt: true,
|
|
@@ -349,6 +380,47 @@ const getReportDetail = async (id) => {
|
|
|
349
380
|
return { ...meta, features: content?.features ?? [] };
|
|
350
381
|
};
|
|
351
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
|
+
|
|
352
424
|
// ---------------------------------------------------------------------------
|
|
353
425
|
// Write operations
|
|
354
426
|
// ---------------------------------------------------------------------------
|
|
@@ -360,7 +432,8 @@ const getReportDetail = async (id) => {
|
|
|
360
432
|
* rawCucumberJson: object[],
|
|
361
433
|
* tags: string,
|
|
362
434
|
* triggerType: string,
|
|
363
|
-
*
|
|
435
|
+
* runnerCount?: number,
|
|
436
|
+
* workerCount?: number,
|
|
364
437
|
* browser?: string,
|
|
365
438
|
* runnerName?: string,
|
|
366
439
|
* runnerId?: string,
|
|
@@ -371,7 +444,8 @@ const saveReport = async ({
|
|
|
371
444
|
rawCucumberJson,
|
|
372
445
|
tags,
|
|
373
446
|
triggerType,
|
|
374
|
-
|
|
447
|
+
runnerCount = 1,
|
|
448
|
+
workerCount = 1,
|
|
375
449
|
browser,
|
|
376
450
|
runnerName,
|
|
377
451
|
runnerId,
|
|
@@ -382,7 +456,11 @@ const saveReport = async ({
|
|
|
382
456
|
attempts = {}
|
|
383
457
|
}) => {
|
|
384
458
|
const normTrigger = normaliseTrigger(triggerType);
|
|
385
|
-
const {
|
|
459
|
+
const {
|
|
460
|
+
features,
|
|
461
|
+
recordings,
|
|
462
|
+
status: derivedStatus
|
|
463
|
+
} = processCucumberJson(rawCucumberJson, attempts);
|
|
386
464
|
const status = forceFail ? REPORT_STATUS.FAIL : derivedStatus;
|
|
387
465
|
const cronJobId = await resolveCronJobId(normTrigger);
|
|
388
466
|
|
|
@@ -391,7 +469,8 @@ const saveReport = async ({
|
|
|
391
469
|
status,
|
|
392
470
|
tags: (tags ?? '').replace(/^\(|\)$/g, '') || '@all-tests',
|
|
393
471
|
triggerType: normTrigger,
|
|
394
|
-
|
|
472
|
+
runnerCount,
|
|
473
|
+
workerCount,
|
|
395
474
|
browser: browser ?? DEFAULT_BROWSER,
|
|
396
475
|
runnerName: runnerName ?? null,
|
|
397
476
|
runnerId: runnerId ?? null,
|
|
@@ -402,17 +481,23 @@ const saveReport = async ({
|
|
|
402
481
|
duration
|
|
403
482
|
}
|
|
404
483
|
});
|
|
484
|
+
if (recordings.length > 0) {
|
|
485
|
+
await prisma.recording.createMany({
|
|
486
|
+
data: recordings.map((r) => ({ ...r, reportId: report.id }))
|
|
487
|
+
});
|
|
488
|
+
}
|
|
405
489
|
syncAutomatedTags(report.id, features, testRunId ?? null);
|
|
406
490
|
return report;
|
|
407
491
|
};
|
|
408
492
|
|
|
409
493
|
/**
|
|
410
|
-
* Merges Cucumber JSON from all distributed lanes
|
|
411
|
-
*
|
|
494
|
+
* Merges Cucumber JSON from all distributed lanes and persists one combined
|
|
495
|
+
* report to the database.
|
|
412
496
|
*
|
|
413
497
|
* @param {{
|
|
414
498
|
* reports: (string|null)[],
|
|
415
499
|
* runners: { id: string, name: string, dbId: string|null }[],
|
|
500
|
+
* workers?: number,
|
|
416
501
|
* overallCode: number,
|
|
417
502
|
* tag: string,
|
|
418
503
|
* triggerType: string,
|
|
@@ -423,6 +508,7 @@ const saveReport = async ({
|
|
|
423
508
|
const saveCombinedReport = async ({
|
|
424
509
|
reports,
|
|
425
510
|
runners,
|
|
511
|
+
workers = 1,
|
|
426
512
|
overallCode,
|
|
427
513
|
tag,
|
|
428
514
|
triggerType,
|
|
@@ -433,15 +519,23 @@ const saveCombinedReport = async ({
|
|
|
433
519
|
attemptsByLane = null
|
|
434
520
|
}) => {
|
|
435
521
|
const featureMap = new Map();
|
|
436
|
-
|
|
437
|
-
if (!content)
|
|
522
|
+
reports.forEach((content, laneIdx) => {
|
|
523
|
+
if (!content) return;
|
|
438
524
|
let parsed;
|
|
439
525
|
try {
|
|
440
526
|
parsed = JSON.parse(content);
|
|
441
527
|
} catch {
|
|
442
|
-
|
|
528
|
+
return;
|
|
443
529
|
}
|
|
530
|
+
const lane = runners[laneIdx];
|
|
444
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
|
+
}
|
|
445
539
|
// Each lane runs from its own temp dir, so the same feature reports a
|
|
446
540
|
// different absolute uri per runner. Key on the path from `features/`
|
|
447
541
|
// onward (falling back to name) so one feature's scenarios from every
|
|
@@ -453,7 +547,7 @@ const saveCombinedReport = async ({
|
|
|
453
547
|
featureMap.set(key, { ...feature, elements: [...(feature.elements ?? [])] });
|
|
454
548
|
}
|
|
455
549
|
}
|
|
456
|
-
}
|
|
550
|
+
});
|
|
457
551
|
const combined = [...featureMap.values()];
|
|
458
552
|
|
|
459
553
|
let combinedLogs = null;
|
|
@@ -472,7 +566,8 @@ const saveCombinedReport = async ({
|
|
|
472
566
|
rawCucumberJson: combined,
|
|
473
567
|
tags: tag,
|
|
474
568
|
triggerType,
|
|
475
|
-
|
|
569
|
+
runnerCount: runners.length,
|
|
570
|
+
workerCount: workers,
|
|
476
571
|
browser,
|
|
477
572
|
runnerName: runners.map((r) => r.name).join(', '),
|
|
478
573
|
runnerId: null,
|
|
@@ -502,17 +597,10 @@ const attachDurationToLatestReport = async ({ afterTimestamp, duration }) => {
|
|
|
502
597
|
// ---------------------------------------------------------------------------
|
|
503
598
|
|
|
504
599
|
const deleteReport = async (id) => {
|
|
505
|
-
const report = await prisma.report.findUnique({ where: { id }, select: { content: true } });
|
|
506
|
-
if (report) deleteScreenshotFiles(report.content);
|
|
507
600
|
await prisma.report.delete({ where: { id } });
|
|
508
601
|
};
|
|
509
602
|
|
|
510
603
|
const deleteReports = async (ids) => {
|
|
511
|
-
const reports = await prisma.report.findMany({
|
|
512
|
-
where: { id: { in: ids } },
|
|
513
|
-
select: { content: true }
|
|
514
|
-
});
|
|
515
|
-
for (const r of reports) deleteScreenshotFiles(r.content);
|
|
516
604
|
await prisma.report.deleteMany({ where: { id: { in: ids } } });
|
|
517
605
|
};
|
|
518
606
|
|
|
@@ -540,6 +628,8 @@ module.exports = {
|
|
|
540
628
|
getReports,
|
|
541
629
|
getLatestReportId,
|
|
542
630
|
getReportDetail,
|
|
631
|
+
getRecordings,
|
|
632
|
+
getRecordingEvents,
|
|
543
633
|
saveReport,
|
|
544
634
|
saveCombinedReport,
|
|
545
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 },
|
|
@@ -86,13 +86,14 @@ async function create({ suiteId, title, description, priority, createdById }) {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async function update(id, { title, description, priority }) {
|
|
89
|
+
async function update(id, { title, description, priority, suiteId }) {
|
|
90
90
|
return prisma.testCase.update({
|
|
91
91
|
where: { id },
|
|
92
92
|
data: {
|
|
93
93
|
...(title !== undefined && { title }),
|
|
94
94
|
...(description !== undefined && { description }),
|
|
95
|
-
...(priority !== undefined && { priority })
|
|
95
|
+
...(priority !== undefined && { priority }),
|
|
96
|
+
...(suiteId !== undefined && { suiteId })
|
|
96
97
|
},
|
|
97
98
|
select: caseSelect
|
|
98
99
|
});
|
|
@@ -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);
|