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.
Files changed (235) hide show
  1. package/backend/_scaffold/utils/browser.ts +184 -30
  2. package/backend/_scaffold/utils/hooks.ts +37 -8
  3. package/backend/app.js +0 -5
  4. package/backend/config/scripts/generate-report.js +2 -2
  5. package/backend/constants/socketEvents.js +7 -4
  6. package/backend/lib/reportFilename.js +1 -2
  7. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  8. package/backend/lib/serverBootstrap.js +14 -0
  9. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  10. package/backend/mcp/server.js +3 -47
  11. package/backend/package-lock.json +199 -1
  12. package/backend/package.json +3 -1
  13. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  14. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  15. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  16. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  17. package/backend/prisma/schema.prisma +97 -70
  18. package/backend/routes/backup.routes.js +47 -1
  19. package/backend/routes/reports.routes.js +23 -0
  20. package/backend/routes/test-cases.routes.js +7 -2
  21. package/backend/server.js +1 -1
  22. package/backend/services/backupCronService.js +1 -1
  23. package/backend/services/backupService.js +134 -44
  24. package/backend/services/cronService.js +23 -15
  25. package/backend/services/nodeExecutionService.js +41 -15
  26. package/backend/services/nodeStreamRegistry.js +24 -0
  27. package/backend/services/reportService.js +174 -84
  28. package/backend/services/runnerService.js +19 -7
  29. package/backend/services/settingsService.js +6 -3
  30. package/backend/services/testCaseService.js +3 -2
  31. package/backend/services/triggerService.js +20 -13
  32. package/backend/services/userService.js +1 -3
  33. package/backend/websockets/nodeSocketHandler.js +40 -0
  34. package/backend/websockets/socketHandler.js +9 -7
  35. package/frontend/.svelte-kit/adapter-node/.vite/manifest.json +1 -1
  36. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.C1TbMUws.css +6 -0
  37. package/frontend/.svelte-kit/adapter-node/chunks/internal.js +1 -1
  38. package/frontend/.svelte-kit/adapter-node/entries/pages/_page.svelte.js +22 -22
  39. package/frontend/.svelte-kit/adapter-node/entries/pages/settings/_page.svelte.js +3 -3
  40. package/frontend/.svelte-kit/adapter-node/manifest-full.js +1 -1
  41. package/frontend/.svelte-kit/adapter-node/manifest.js +1 -1
  42. package/frontend/.svelte-kit/adapter-node/nodes/0.js +1 -1
  43. package/frontend/.svelte-kit/adapter-node/nodes/1.js +1 -1
  44. package/frontend/.svelte-kit/adapter-node/nodes/11.js +1 -1
  45. package/frontend/.svelte-kit/adapter-node/nodes/12.js +1 -1
  46. package/frontend/.svelte-kit/adapter-node/nodes/2.js +2 -2
  47. package/frontend/.svelte-kit/adapter-node/nodes/3.js +1 -1
  48. package/frontend/.svelte-kit/adapter-node/nodes/5.js +1 -1
  49. package/frontend/.svelte-kit/adapter-node/nodes/6.js +1 -1
  50. package/frontend/.svelte-kit/adapter-node/nodes/8.js +1 -1
  51. package/frontend/.svelte-kit/adapter-node/nodes/9.js +1 -1
  52. package/frontend/.svelte-kit/ambient.d.ts +4 -0
  53. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  54. package/frontend/.svelte-kit/output/client/.vite/manifest.json +35 -35
  55. package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.C1TbMUws.css +6 -0
  56. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.C1TbMUws.css +6 -0
  57. package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{DEKAA-Fm.js → DDZw0L0T.js} +1 -1
  58. package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{Dhf4xQKo.js → kkA1mrsH.js} +2 -2
  59. package/frontend/.svelte-kit/output/client/_app/immutable/entry/{app.B0GH5ONo.js → app.KrtMZ3my.js} +2 -2
  60. package/frontend/.svelte-kit/output/client/_app/immutable/entry/{start.Bn25CW9X.js → start.4qxHHAcl.js} +1 -1
  61. package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{0.-imXt0f5.js → 0.C8DodwXs.js} +1 -1
  62. package/frontend/{build/client/_app/immutable/nodes/1.Dom3CKWF.js → .svelte-kit/output/client/_app/immutable/nodes/1.Df9sLw8b.js} +1 -1
  63. package/frontend/{build/client/_app/immutable/nodes/11.DMlayLCK.js → .svelte-kit/output/client/_app/immutable/nodes/11.BViaDK2m.js} +1 -1
  64. package/frontend/{build/client/_app/immutable/nodes/12.Bu89A1eX.js → .svelte-kit/output/client/_app/immutable/nodes/12.BMSCSd88.js} +1 -1
  65. package/frontend/.svelte-kit/output/client/_app/immutable/nodes/2.BCPSg7H4.js +6 -0
  66. package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{3.B86rlHXU.js → 3.8BFAJoHT.js} +1 -1
  67. package/frontend/{build/client/_app/immutable/nodes/5.CqTXvadV.js → .svelte-kit/output/client/_app/immutable/nodes/5.DVeH2eQP.js} +1 -1
  68. package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{6.BJZ62MCd.js → 6.DI7-IJhN.js} +1 -1
  69. package/frontend/{build/client/_app/immutable/nodes/8.B4fj2Xyu.js → .svelte-kit/output/client/_app/immutable/nodes/8.93Mb3ofT.js} +4 -4
  70. package/frontend/{build/client/_app/immutable/nodes/9.C57_2xq6.js → .svelte-kit/output/client/_app/immutable/nodes/9.CvfGx3Hu.js} +1 -1
  71. package/frontend/.svelte-kit/output/client/_app/version.json +1 -1
  72. package/frontend/.svelte-kit/output/server/.vite/manifest.json +1 -1
  73. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.C1TbMUws.css +6 -0
  74. package/frontend/.svelte-kit/output/server/chunks/internal.js +1 -1
  75. package/frontend/.svelte-kit/output/server/entries/pages/_page.svelte.js +22 -22
  76. package/frontend/.svelte-kit/output/server/entries/pages/settings/_page.svelte.js +3 -3
  77. package/frontend/.svelte-kit/output/server/manifest-full.js +1 -1
  78. package/frontend/.svelte-kit/output/server/manifest.js +1 -1
  79. package/frontend/.svelte-kit/output/server/nodes/0.js +1 -1
  80. package/frontend/.svelte-kit/output/server/nodes/1.js +1 -1
  81. package/frontend/.svelte-kit/output/server/nodes/11.js +1 -1
  82. package/frontend/.svelte-kit/output/server/nodes/12.js +1 -1
  83. package/frontend/.svelte-kit/output/server/nodes/2.js +2 -2
  84. package/frontend/.svelte-kit/output/server/nodes/3.js +1 -1
  85. package/frontend/.svelte-kit/output/server/nodes/5.js +1 -1
  86. package/frontend/.svelte-kit/output/server/nodes/6.js +1 -1
  87. package/frontend/.svelte-kit/output/server/nodes/8.js +1 -1
  88. package/frontend/.svelte-kit/output/server/nodes/9.js +1 -1
  89. package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css +6 -0
  90. package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css.br +0 -0
  91. package/frontend/build/client/_app/immutable/assets/2.C1TbMUws.css.gz +0 -0
  92. package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css +6 -0
  93. package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css.br +0 -0
  94. package/frontend/build/client/_app/immutable/assets/_page.C1TbMUws.css.gz +0 -0
  95. package/frontend/build/client/_app/immutable/chunks/{DEKAA-Fm.js → DDZw0L0T.js} +1 -1
  96. package/frontend/build/client/_app/immutable/chunks/DDZw0L0T.js.br +0 -0
  97. package/frontend/build/client/_app/immutable/chunks/DDZw0L0T.js.gz +0 -0
  98. package/frontend/build/client/_app/immutable/chunks/{Dhf4xQKo.js → kkA1mrsH.js} +2 -2
  99. package/frontend/build/client/_app/immutable/chunks/kkA1mrsH.js.br +0 -0
  100. package/frontend/build/client/_app/immutable/chunks/kkA1mrsH.js.gz +0 -0
  101. package/frontend/build/client/_app/immutable/entry/{app.B0GH5ONo.js → app.KrtMZ3my.js} +2 -2
  102. package/frontend/build/client/_app/immutable/entry/app.KrtMZ3my.js.br +0 -0
  103. package/frontend/build/client/_app/immutable/entry/app.KrtMZ3my.js.gz +0 -0
  104. package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js → start.4qxHHAcl.js} +1 -1
  105. package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js.br → start.4qxHHAcl.js.br} +0 -0
  106. package/frontend/build/client/_app/immutable/entry/{start.Bn25CW9X.js.gz → start.4qxHHAcl.js.gz} +0 -0
  107. package/frontend/build/client/_app/immutable/nodes/{0.-imXt0f5.js → 0.C8DodwXs.js} +1 -1
  108. package/frontend/build/client/_app/immutable/nodes/0.C8DodwXs.js.br +0 -0
  109. package/frontend/build/client/_app/immutable/nodes/0.C8DodwXs.js.gz +0 -0
  110. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/1.Dom3CKWF.js → build/client/_app/immutable/nodes/1.Df9sLw8b.js} +1 -1
  111. package/frontend/build/client/_app/immutable/nodes/1.Df9sLw8b.js.br +0 -0
  112. package/frontend/build/client/_app/immutable/nodes/1.Df9sLw8b.js.gz +0 -0
  113. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/11.DMlayLCK.js → build/client/_app/immutable/nodes/11.BViaDK2m.js} +1 -1
  114. package/frontend/build/client/_app/immutable/nodes/11.BViaDK2m.js.br +0 -0
  115. package/frontend/build/client/_app/immutable/nodes/11.BViaDK2m.js.gz +0 -0
  116. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/12.Bu89A1eX.js → build/client/_app/immutable/nodes/12.BMSCSd88.js} +1 -1
  117. package/frontend/build/client/_app/immutable/nodes/12.BMSCSd88.js.br +0 -0
  118. package/frontend/build/client/_app/immutable/nodes/{12.Bu89A1eX.js.gz → 12.BMSCSd88.js.gz} +0 -0
  119. package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js +6 -0
  120. package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js.br +0 -0
  121. package/frontend/build/client/_app/immutable/nodes/2.BCPSg7H4.js.gz +0 -0
  122. package/frontend/build/client/_app/immutable/nodes/{3.B86rlHXU.js → 3.8BFAJoHT.js} +1 -1
  123. package/frontend/build/client/_app/immutable/nodes/3.8BFAJoHT.js.br +0 -0
  124. package/frontend/build/client/_app/immutable/nodes/3.8BFAJoHT.js.gz +0 -0
  125. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/5.CqTXvadV.js → build/client/_app/immutable/nodes/5.DVeH2eQP.js} +1 -1
  126. package/frontend/build/client/_app/immutable/nodes/5.DVeH2eQP.js.br +0 -0
  127. package/frontend/build/client/_app/immutable/nodes/5.DVeH2eQP.js.gz +0 -0
  128. package/frontend/build/client/_app/immutable/nodes/{6.BJZ62MCd.js → 6.DI7-IJhN.js} +1 -1
  129. package/frontend/build/client/_app/immutable/nodes/6.DI7-IJhN.js.br +0 -0
  130. package/frontend/build/client/_app/immutable/nodes/6.DI7-IJhN.js.gz +0 -0
  131. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/8.B4fj2Xyu.js → build/client/_app/immutable/nodes/8.93Mb3ofT.js} +4 -4
  132. package/frontend/build/client/_app/immutable/nodes/8.93Mb3ofT.js.br +0 -0
  133. package/frontend/build/client/_app/immutable/nodes/8.93Mb3ofT.js.gz +0 -0
  134. package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/9.C57_2xq6.js → build/client/_app/immutable/nodes/9.CvfGx3Hu.js} +1 -1
  135. package/frontend/build/client/_app/immutable/nodes/9.CvfGx3Hu.js.br +0 -0
  136. package/frontend/build/client/_app/immutable/nodes/9.CvfGx3Hu.js.gz +0 -0
  137. package/frontend/build/client/_app/version.json +1 -1
  138. package/frontend/build/client/_app/version.json.br +0 -0
  139. package/frontend/build/client/_app/version.json.gz +0 -0
  140. package/frontend/build/server/chunks/{0-DG1Uv07a.js → 0-D5YWFygh.js} +2 -2
  141. package/frontend/build/server/chunks/{0-DG1Uv07a.js.map → 0-D5YWFygh.js.map} +1 -1
  142. package/frontend/build/server/chunks/{1-iB0ogJU_.js → 1-Kxn9d30Z.js} +2 -2
  143. package/frontend/build/server/chunks/{1-iB0ogJU_.js.map → 1-Kxn9d30Z.js.map} +1 -1
  144. package/frontend/build/server/chunks/{11-Df9vGZ_v.js → 11-CNLerze_.js} +2 -2
  145. package/frontend/build/server/chunks/{11-Df9vGZ_v.js.map → 11-CNLerze_.js.map} +1 -1
  146. package/frontend/build/server/chunks/{12-DqvjCln4.js → 12-De6Ol6pu.js} +2 -2
  147. package/frontend/build/server/chunks/{12-DqvjCln4.js.map → 12-De6Ol6pu.js.map} +1 -1
  148. package/frontend/build/server/chunks/{2-Cte30Dgy.js → 2-Dc_rKWoK.js} +4 -4
  149. package/frontend/build/server/chunks/{2-Cte30Dgy.js.map → 2-Dc_rKWoK.js.map} +1 -1
  150. package/frontend/build/server/chunks/{3-BYKCZi4U.js → 3-DrRqVrcX.js} +2 -2
  151. package/frontend/build/server/chunks/{3-BYKCZi4U.js.map → 3-DrRqVrcX.js.map} +1 -1
  152. package/frontend/build/server/chunks/{5-B6Jc1foX.js → 5-CFeE5dPZ.js} +2 -2
  153. package/frontend/build/server/chunks/{5-B6Jc1foX.js.map → 5-CFeE5dPZ.js.map} +1 -1
  154. package/frontend/build/server/chunks/{6-dXZKtN86.js → 6-DZk37MZH.js} +2 -2
  155. package/frontend/build/server/chunks/{6-dXZKtN86.js.map → 6-DZk37MZH.js.map} +1 -1
  156. package/frontend/build/server/chunks/{8-D-Bp7HzS.js → 8-CKPVFOqJ.js} +3 -3
  157. package/frontend/build/server/chunks/{8-D-Bp7HzS.js.map → 8-CKPVFOqJ.js.map} +1 -1
  158. package/frontend/build/server/chunks/{9-BynjuGUO.js → 9-DirZG_Lu.js} +2 -2
  159. package/frontend/build/server/chunks/{9-BynjuGUO.js.map → 9-DirZG_Lu.js.map} +1 -1
  160. package/frontend/build/server/chunks/{_page.svelte-CpB-fgJl.js → _page.svelte-04WxjOuH.js} +23 -23
  161. package/frontend/build/server/chunks/_page.svelte-04WxjOuH.js.map +5 -0
  162. package/frontend/build/server/chunks/{_page.svelte-wKyUmA2e.js → _page.svelte-8gELc_B1.js} +4 -4
  163. package/frontend/build/server/chunks/_page.svelte-8gELc_B1.js.map +5 -0
  164. package/frontend/build/server/index.js +1 -1
  165. package/frontend/build/server/index.js.map +1 -1
  166. package/frontend/build/server/manifest.js +11 -11
  167. package/frontend/build/server/manifest.js.map +1 -1
  168. package/frontend/package-lock.json +121 -32
  169. package/frontend/package.json +1 -0
  170. package/frontend/src/lib/api/reports.js +13 -4
  171. package/frontend/src/lib/api/repository.js +2 -2
  172. package/frontend/src/lib/api/settings.js +16 -1
  173. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  174. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  175. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  176. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  177. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  178. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  179. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  180. package/frontend/src/lib/constants.js +0 -1
  181. package/frontend/src/lib/copy/reports.js +18 -8
  182. package/frontend/src/lib/copy/repository.js +6 -0
  183. package/frontend/src/lib/copy/settings.js +25 -4
  184. package/frontend/src/lib/socketEvents.js +7 -4
  185. package/frontend/src/lib/stores/runner.js +26 -3
  186. package/frontend/src/lib/styles/tokens.css +7 -0
  187. package/frontend/src/lib/utils/format.js +108 -2
  188. package/frontend/src/lib/utils/inspectElement.js +34 -0
  189. package/frontend/src/routes/+page.svelte +6 -1
  190. package/frontend/src/routes/reports/+page.svelte +79 -1
  191. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  192. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  193. package/frontend/src/routes/settings/+page.svelte +246 -8
  194. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +116 -0
  195. package/package.json +1 -1
  196. package/backend/playwright.config.js +0 -85
  197. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
  198. package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.DvMkmYA-.css +0 -6
  199. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
  200. package/frontend/.svelte-kit/output/client/_app/immutable/nodes/2.BnHalSCi.js +0 -6
  201. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
  202. package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css +0 -6
  203. package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css.br +0 -0
  204. package/frontend/build/client/_app/immutable/assets/2.DvMkmYA-.css.gz +0 -0
  205. package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css +0 -6
  206. package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css.br +0 -0
  207. package/frontend/build/client/_app/immutable/assets/_page.DvMkmYA-.css.gz +0 -0
  208. package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.br +0 -0
  209. package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.gz +0 -0
  210. package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.br +0 -0
  211. package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.gz +0 -0
  212. package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.br +0 -0
  213. package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.gz +0 -0
  214. package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.br +0 -0
  215. package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.gz +0 -0
  216. package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.br +0 -0
  217. package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.gz +0 -0
  218. package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.br +0 -0
  219. package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.gz +0 -0
  220. package/frontend/build/client/_app/immutable/nodes/12.Bu89A1eX.js.br +0 -0
  221. package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js +0 -6
  222. package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js.br +0 -0
  223. package/frontend/build/client/_app/immutable/nodes/2.BnHalSCi.js.gz +0 -0
  224. package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.br +0 -0
  225. package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.gz +0 -0
  226. package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.br +0 -0
  227. package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.gz +0 -0
  228. package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.br +0 -0
  229. package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.gz +0 -0
  230. package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.br +0 -0
  231. package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.gz +0 -0
  232. package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.br +0 -0
  233. package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.gz +0 -0
  234. package/frontend/build/server/chunks/_page.svelte-CpB-fgJl.js.map +0 -5
  235. 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 crypto = require('crypto');
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
- const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
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((e) => e.id !== scenario.id);
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
- function deleteScreenshotFiles(content) {
191
- for (const file of collectScreenshotFiles(content)) {
192
- const p = path.join(SCREENSHOTS_DIR, file);
193
- try {
194
- if (fs.existsSync(p)) fs.unlinkSync(p);
195
- } catch {}
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 base64 screenshots to PNG files on disk
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
- fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
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
- }
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: step.result?.status ?? 'pending',
274
+ status,
249
275
  duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
250
276
  error: step.result?.error_message ?? null,
251
- screenshot: screenshotFile
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
- runners: true,
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
- runners: true,
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
- * nodeCount?: number,
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
- nodeCount,
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 { features, status: derivedStatus } = processCucumberJson(rawCucumberJson, attempts);
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
- runners: nodeCount ?? 1,
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, processes screenshots,
411
- * and persists one combined report to the database.
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
- for (const content of reports) {
437
- if (!content) continue;
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
- continue;
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
- nodeCount: runners.length,
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
- onScreenshot = null
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 { startSsPoller } = require('../lib/screenshotPoller');
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
- onScreenshot
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 = startSsPoller(ssDir, onScreenshot);
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
- onScreenshot
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
- onScreenshot
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
- onScreenshot
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
- onScreenshot
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/screenshot/done) and the job store
195
- // (polled through getJob).
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 onScreenshot = ({ stepName, data }) => {
222
- if (_io) _io.emit(SOCKET_EVENTS.BG_RUN_SCREENSHOT, { runId: jobId, stepName, data });
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
- onScreenshot
242
+ onRRwebBatch
236
243
  };
237
244
  if (maxRetries === 0) {
238
245
  runNoRetry(runParams);