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
@@ -15,16 +15,71 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
+ // Wires up Plum's session recording — removing or reordering code here can silently break report replay.
19
+
18
20
  import { chromium, firefox, webkit, Browser, BrowserContext, Page } from 'playwright';
19
21
  import * as fs from 'fs';
20
22
  import * as path from 'path';
23
+ import * as zlib from 'zlib';
24
+
25
+ // Must match the mime type Plum's server expects — do not change.
26
+ const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
27
+ // Always attached, even for a scenario with no recorded events, so the
28
+ // worker that ran it is still recoverable for grouping.
29
+ const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
30
+ // @rrweb/record's package.json only exports its main entry ("."), so a deep
31
+ // require.resolve() of the UMD bundle is blocked by Node's exports map — resolve
32
+ // the (exported) main entry instead and locate the sibling file on disk.
33
+ const RECORD_BUNDLE_PATH = path.join(
34
+ path.dirname(require.resolve('@rrweb/record')),
35
+ 'record.umd.min.cjs'
36
+ );
37
+
38
+ interface TabRecording {
39
+ tabId: string;
40
+ tabIndex: number;
41
+ events: unknown[];
42
+ openedAt: number;
43
+ closedAt: number | null;
44
+ liveFlushedCount: number;
45
+ }
21
46
 
22
47
  let _browser: Browser;
23
48
  let _context: BrowserContext;
24
49
  let _page: Page;
25
- let _ssCounter = 0;
50
+ let _liveRRwebCounter = 0;
51
+ let _liveRRwebTimer: ReturnType<typeof setInterval> | null = null;
52
+ let _tabs: Map<Page, TabRecording> = new Map();
53
+ let _tabCounter = 0;
54
+ let _workerId = 1;
26
55
 
27
56
  export const page = (): Page => _page;
57
+ export const context = (): BrowserContext => _context;
58
+
59
+ function tabIdForIndex(index: number): string {
60
+ return index === 0 ? 'main' : `tab-${index + 1}`;
61
+ }
62
+
63
+ // A static page (nothing left to interact with) can go a long time between
64
+ // rrweb events, or emit none at all after its initial load — its own event
65
+ // timestamps are a poor proxy for how long it stayed relevant. Real
66
+ // open/close times let the replay UI line multiple tabs up on one timeline
67
+ // without guessing from event gaps.
68
+ function attachRecorder(pg: Page): void {
69
+ const tabIndex = _tabCounter++;
70
+ const recording: TabRecording = {
71
+ tabId: tabIdForIndex(tabIndex),
72
+ tabIndex,
73
+ events: [],
74
+ openedAt: Date.now(),
75
+ closedAt: null,
76
+ liveFlushedCount: 0
77
+ };
78
+ _tabs.set(pg, recording);
79
+ pg.on('close', () => {
80
+ recording.closedAt = Date.now();
81
+ });
82
+ }
28
83
 
29
84
  export async function setup(): Promise<void> {
30
85
  const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
@@ -33,50 +88,149 @@ export async function setup(): Promise<void> {
33
88
  browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
34
89
  _browser = await browserType.launch({ headless: isHeadless });
35
90
  _context = await _browser.newContext();
91
+
92
+ _tabs = new Map();
93
+ _tabCounter = 0;
94
+ // Cucumber forks one OS process per --parallel worker and injects this env
95
+ // var into each — 0-indexed, so display/report as 1-based like the rest of
96
+ // the worker-count UI.
97
+ const parsedWorkerId = parseInt(process.env.CUCUMBER_WORKER_ID ?? '', 10);
98
+ _workerId = Number.isFinite(parsedWorkerId) ? parsedWorkerId + 1 : 1;
99
+
100
+ // Context-level exposeBinding/addInitScript apply to every page in the
101
+ // context automatically — current and future (popups, target=_blank tabs) —
102
+ // so recording setup never races a new tab's first navigation.
103
+ await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson: string) => {
104
+ const recording = source.page && _tabs.get(source.page);
105
+ if (!recording) return;
106
+ try {
107
+ recording.events.push(JSON.parse(eventJson));
108
+ } catch {
109
+ // malformed event — drop it, recording is best-effort
110
+ }
111
+ });
112
+ await _context.addInitScript({ path: RECORD_BUNDLE_PATH });
113
+ await _context.addInitScript(() => {
114
+ // addInitScript runs in every frame, including hidden ad/tracking iframes.
115
+ // Recordings are tracked per-Page, so an unguarded sub-frame session would
116
+ // corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
117
+ // @ts-ignore
118
+ if (window.self !== window.top) return;
119
+ // @ts-ignore
120
+ if (window.rrwebRecord) {
121
+ // @ts-ignore
122
+ window.rrwebRecord.record({
123
+ emit: (event: unknown) => {
124
+ // @ts-ignore — exposed by BrowserContext.exposeBinding above
125
+ window.__plumEmitRRwebEvent(JSON.stringify(event));
126
+ }
127
+ });
128
+ }
129
+ });
130
+
131
+ _context.on('page', attachRecorder);
36
132
  _page = await _context.newPage();
133
+
134
+ // Only when someone's actually watching live — a scheduled/background run
135
+ // with no viewer shouldn't pay for this.
136
+ if (process.env.PLUM_SS_DIR) {
137
+ _liveRRwebTimer = setInterval(flushLiveRRwebEvents, 500);
138
+ }
37
139
  }
38
140
 
39
- export async function screenshotStep(
40
- attach: (data: Buffer, mime: string) => Promise<void>
41
- ): Promise<void> {
141
+ // Sends only what's newly arrived since the last tick, per tab, so the live
142
+ // viewer gets a steady trickle instead of the full buffer growing unbounded.
143
+ function flushLiveRRwebEvents(): void {
144
+ const ssDir = process.env.PLUM_SS_DIR;
145
+ if (!ssDir) return;
146
+ for (const recording of _tabs.values()) {
147
+ const newEvents = recording.events.slice(recording.liveFlushedCount);
148
+ if (newEvents.length === 0) continue;
149
+ recording.liveFlushedCount = recording.events.length;
150
+ try {
151
+ const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_liveRRwebCounter).padStart(4, '0')}`;
152
+ fs.writeFileSync(
153
+ path.join(ssDir, `${seq}.rrweb.json`),
154
+ JSON.stringify({
155
+ workerId: _workerId,
156
+ tabId: recording.tabId,
157
+ tabIndex: recording.tabIndex,
158
+ events: newEvents
159
+ })
160
+ );
161
+ } catch {
162
+ // best-effort — live streaming shouldn't affect the recording itself
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Injects a labeled rrweb custom event at the current recording timestamp so
169
+ * the replay UI can show which step was running at any point in the timeline.
170
+ */
171
+ export async function markStepStart(stepName: string): Promise<void> {
42
172
  if (!_page) return;
43
173
  try {
44
- const screenshot = await _page.screenshot({ type: 'png' });
45
- await attach(screenshot, 'image/png');
174
+ await _page.evaluate((name) => {
175
+ // @ts-ignore — rrwebRecord is injected by the record.umd.min.cjs bundle
176
+ if (window.rrwebRecord?.record?.addCustomEvent) {
177
+ // @ts-ignore
178
+ window.rrwebRecord.record.addCustomEvent('step', { name });
179
+ }
180
+ }, stepName);
46
181
  } catch {
47
- // page may be navigating or already closed
182
+ // best-effort a missing marker just means the replay UI won't show a
183
+ // step label at that point, it doesn't affect the recording itself
48
184
  }
49
185
  }
50
186
 
51
- export async function streamLiveScreenshot(stepName: string): Promise<void> {
52
- const ssDir = process.env.PLUM_SS_DIR;
53
- if (!ssDir || !_page) return;
187
+ /**
188
+ * Flushes every tab's buffered rrweb events (one per opened tab/popup) as a
189
+ * gzip-compressed Cucumber attachment, tagged with the mime type Plum's
190
+ * server looks for.
191
+ */
192
+ export async function flushRecordings(
193
+ attach: (data: Buffer, mime: string) => Promise<void>
194
+ ): Promise<void> {
195
+ if (_liveRRwebTimer) {
196
+ clearInterval(_liveRRwebTimer);
197
+ _liveRRwebTimer = null;
198
+ }
199
+ // One last live flush so the stream doesn't miss whatever happened between
200
+ // the final tick and scenario end.
201
+ flushLiveRRwebEvents();
202
+
54
203
  try {
55
- const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_ssCounter).padStart(4, '0')}`;
56
- const screenshot = await _page.screenshot({ type: 'jpeg', quality: 70 });
57
- fs.writeFileSync(
58
- path.join(ssDir, `${seq}.ss.json`),
59
- JSON.stringify({ stepName, data: screenshot.toString('base64') })
204
+ await attach(
205
+ Buffer.from(JSON.stringify({ workerId: _workerId }), 'utf8'),
206
+ WORKER_META_MIME_TYPE
60
207
  );
61
208
  } catch {
62
- // ignorelive streaming is best-effort
209
+ // best-efforta missing worker marker just falls back to workerId 1
63
210
  }
64
- }
65
211
 
66
- export async function teardown(
67
- attach: (data: Buffer, mime: string) => Promise<void>,
68
- failed: boolean
69
- ): Promise<void> {
70
- if (failed && _page) {
71
- const screenshotDir = 'reports/screenshots';
72
- if (!fs.existsSync(screenshotDir)) {
73
- fs.mkdirSync(screenshotDir, { recursive: true });
212
+ const flushedAt = Date.now();
213
+ for (const recording of _tabs.values()) {
214
+ if (recording.events.length === 0) continue;
215
+ try {
216
+ const payload = JSON.stringify({
217
+ workerId: _workerId,
218
+ tabId: recording.tabId,
219
+ tabIndex: recording.tabIndex,
220
+ events: recording.events,
221
+ openedAt: recording.openedAt,
222
+ // A tab still open when the scenario ends (typically the main tab)
223
+ // stayed relevant through to the flush, not just its last DOM event.
224
+ closedAt: recording.closedAt ?? flushedAt
225
+ });
226
+ const gz = zlib.gzipSync(Buffer.from(payload, 'utf8'));
227
+ await attach(gz, RRWEB_MIME_TYPE);
228
+ } catch {
229
+ // a failed recording flush shouldn't fail the scenario
74
230
  }
75
- const screenshotPath = path.join(screenshotDir, `screenshot_${Date.now()}.png`);
76
- await _page.screenshot({ path: screenshotPath });
77
- const screenshotData = fs.readFileSync(screenshotPath);
78
- await attach(screenshotData, 'image/png');
79
- fs.unlinkSync(screenshotPath);
80
231
  }
232
+ }
233
+
234
+ export async function teardown(): Promise<void> {
81
235
  await _browser?.close();
82
236
  }
@@ -15,24 +15,53 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import { Before, After, AfterStep, ITestCaseHookParameter } from '@cucumber/cucumber';
19
- import { setup, teardown, screenshotStep, streamLiveScreenshot } from './browser';
18
+ // Wires up Plum's session recording removing or reordering code here can silently break report replay.
19
+
20
+ import { Before, After, BeforeStep, ITestCaseHookParameter } from '@cucumber/cucumber';
21
+ import { setup, teardown, flushRecordings, markStepStart } from './browser';
20
22
  import dotenv from 'dotenv';
21
23
 
22
24
  dotenv.config();
23
25
 
26
+ /**
27
+ * Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
28
+ * away during Gherkin → Pickle compilation) — recover it by walking the
29
+ * gherkinDocument for the AST node the pickle step was compiled from.
30
+ */
31
+ function resolveStepKeyword(gherkinDocument: any, pickleStep: any): string {
32
+ const astNodeId = pickleStep?.astNodeIds?.[0];
33
+ if (!astNodeId) return '';
34
+ const steps: any[] = [];
35
+ for (const child of gherkinDocument?.feature?.children ?? []) {
36
+ if (child.background) steps.push(...child.background.steps);
37
+ if (child.scenario) steps.push(...child.scenario.steps);
38
+ for (const ruleChild of child.rule?.children ?? []) {
39
+ if (ruleChild.background) steps.push(...ruleChild.background.steps);
40
+ if (ruleChild.scenario) steps.push(...ruleChild.scenario.steps);
41
+ }
42
+ }
43
+ return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
44
+ }
45
+
24
46
  Before(async ({ pickle }: ITestCaseHookParameter) => {
25
47
  const tags = pickle.tags.map((t) => t.name).join(' ');
26
48
  console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
27
49
  await setup();
28
50
  });
29
51
 
30
- AfterStep(async function ({ pickleStep, result }: { pickleStep: any; result: any }) {
31
- if (result?.status === 'SKIPPED') return;
32
- await screenshotStep(this.attach.bind(this));
33
- await streamLiveScreenshot(pickleStep?.text ?? '');
52
+ BeforeStep(async function ({
53
+ pickleStep,
54
+ gherkinDocument
55
+ }: {
56
+ pickleStep: any;
57
+ gherkinDocument: any;
58
+ }) {
59
+ const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
60
+ const text = pickleStep?.text ?? '';
61
+ await markStepStart(keyword ? `${keyword} ${text}` : text);
34
62
  });
35
63
 
36
- After(async function (scenario: ITestCaseHookParameter) {
37
- await teardown(this.attach.bind(this), scenario.result?.status === 'FAILED');
64
+ After(async function () {
65
+ await flushRecordings(this.attach.bind(this));
66
+ await teardown();
38
67
  });
package/backend/app.js CHANGED
@@ -3,10 +3,8 @@
3
3
  * Licensed under the MIT License. See LICENSE file in the project root for details.
4
4
  */
5
5
 
6
- const path = require('path');
7
6
  const express = require('express');
8
7
  const cors = require('cors');
9
- const { SCREENSHOTS_DIR } = require('./lib/reportFilename');
10
8
  const { isNodeMode } = require('./constants/env');
11
9
  const app = express();
12
10
 
@@ -16,9 +14,6 @@ app.use(cors({ origin: '*' }));
16
14
  // before a real test suite does.
17
15
  app.use(express.json({ limit: '500mb' }));
18
16
 
19
- // Serve screenshot files written during report processing
20
- app.use('/screenshots', express.static(SCREENSHOTS_DIR));
21
-
22
17
  // Routes
23
18
  const nodeRoutes = require('./routes/node.routes');
24
19
  app.use('/api', nodeRoutes);
@@ -36,7 +36,7 @@ if (!process.env.DATABASE_URL) {
36
36
  const reportService = require('../../services/reportService');
37
37
  const triggerType = normaliseTrigger(process.env.TRIGGER);
38
38
  const rawTag = process.env.TAG || '@all-tests';
39
- const nodeCount = Math.max(
39
+ const workerCount = Math.max(
40
40
  1,
41
41
  parseInt(process.env.REPORT_RUNNERS || process.env.PARALLEL || '1', 10) || 1
42
42
  );
@@ -45,7 +45,7 @@ if (!process.env.DATABASE_URL) {
45
45
  rawCucumberJson: raw,
46
46
  tags: rawTag,
47
47
  triggerType,
48
- nodeCount,
48
+ workerCount,
49
49
  browser: process.env.BROWSER || DEFAULT_BROWSER,
50
50
  runnerName: process.env.RUNNER_NAME || null,
51
51
  runnerId: process.env.RUNNER_ID || null,
@@ -15,23 +15,26 @@ const SOCKET_EVENTS = Object.freeze({
15
15
  CANCEL_TEST: 'cancel-test',
16
16
  LOG: 'log',
17
17
  DONE: 'done',
18
- STEP_SCREENSHOT: 'step-screenshot',
19
18
 
20
19
  // Multi-lane distributed run (single interactive run, several runners)
21
20
  RUNNER_LANES_INIT: 'runner-lanes-init',
22
21
  RUNNER_LANE_LOG: 'runner-lane-log',
23
22
  RUNNER_LANE_STATUS: 'runner-lane-status',
24
- RUNNER_LANE_SCREENSHOT: 'runner-lane-screenshot',
25
23
 
26
24
  // Background runs (cron / REST / MCP triggered, no single owning socket)
27
25
  BG_RUN_START: 'bg-run-start',
28
26
  BG_RUN_LOG: 'bg-run-log',
29
27
  BG_RUN_DONE: 'bg-run-done',
30
- BG_RUN_SCREENSHOT: 'bg-run-screenshot',
31
28
  BG_RUN_LANES_INIT: 'bg-run-lanes-init',
32
29
  BG_RUN_LANE_LOG: 'bg-run-lane-log',
33
30
  BG_RUN_LANE_STATUS: 'bg-run-lane-status',
34
- BG_RUN_LANE_SCREENSHOT: 'bg-run-lane-screenshot',
31
+
32
+ // Live rrweb streaming — one shape for every run type, always
33
+ // carrying a lane id (BUILT_IN_RUNNER_ID for the plain single-run case) and
34
+ // a workerId, so a single built-in run with --parallel workers is finally
35
+ // attributable per worker instead of one flat interleaved stream.
36
+ RUNNER_LANE_RRWEB_BATCH: 'runner-lane-rrweb-batch',
37
+ BG_RUN_LANE_RRWEB_BATCH: 'bg-run-lane-rrweb-batch',
35
38
 
36
39
  // Global notifications (any client, not tied to a specific run)
37
40
  REPORT_READY: 'report-ready'
@@ -7,7 +7,6 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
 
9
9
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
10
- const SCREENSHOTS_DIR = path.join(REPORTS_DIR, 'screenshots');
11
10
 
12
11
  /**
13
12
  * Reads the transient cucumber_report.json written by the most recent local test run.
@@ -22,4 +21,4 @@ function readCucumberReportFile() {
22
21
  }
23
22
  }
24
23
 
25
- module.exports = { REPORTS_DIR, SCREENSHOTS_DIR, readCucumberReportFile };
24
+ module.exports = { REPORTS_DIR, readCucumberReportFile };
@@ -6,20 +6,23 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
- function startSsPoller(ssDir, onScreenshot) {
9
+ // The test process runs several levels below whatever calls this, with no
10
+ // direct pipe back, so rrweb batches live-stream by small files dropped and
11
+ // picked up here.
12
+ function startRRwebPoller(ssDir, onRRwebBatch) {
10
13
  const seenFiles = new Set();
11
14
  return setInterval(() => {
12
15
  try {
13
16
  const files = fs
14
17
  .readdirSync(ssDir)
15
- .filter((f) => f.endsWith('.ss.json'))
18
+ .filter((f) => f.endsWith('.rrweb.json'))
16
19
  .sort();
17
20
  for (const f of files) {
18
21
  if (seenFiles.has(f)) continue;
19
22
  seenFiles.add(f);
20
23
  const filePath = path.join(ssDir, f);
21
24
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
- onScreenshot(data);
25
+ onRRwebBatch?.(data);
23
26
  try {
24
27
  fs.unlinkSync(filePath);
25
28
  } catch {}
@@ -28,4 +31,4 @@ function startSsPoller(ssDir, onScreenshot) {
28
31
  }, 400);
29
32
  }
30
33
 
31
- module.exports = { startSsPoller };
34
+ module.exports = { startRRwebPoller };
@@ -43,10 +43,12 @@ function wireRealtimeServices(io, isNodeMode) {
43
43
  if (isNodeMode) return { cronService: null, backupCronService: null };
44
44
 
45
45
  const socketHandler = require('../websockets/socketHandler.js');
46
+ const nodeSocketHandler = require('../websockets/nodeSocketHandler.js');
46
47
  const cronService = require('../services/cronService');
47
48
  const backupCronService = require('../services/backupCronService');
48
49
 
49
50
  socketHandler(io);
51
+ nodeSocketHandler(io);
50
52
  cronService.setSocketIO(io);
51
53
  require('../routes/trigger.routes').setSocketIO(io);
52
54
 
@@ -125,6 +127,7 @@ function handleNodeModeStartup(port) {
125
127
 
126
128
  async function handleFullModeStartup(io, testsDir) {
127
129
  syncAutomatedFlags();
130
+ cleanupLegacyScreenshots();
128
131
 
129
132
  const chokidar = await loadChokidar();
130
133
  if (!chokidar) return;
@@ -133,6 +136,17 @@ async function handleFullModeStartup(io, testsDir) {
133
136
  watchReports(chokidar, io);
134
137
  }
135
138
 
139
+ // Report rows no longer reference screenshot files (replaced by rrweb
140
+ // recordings), so any leftover files on disk are dead weight. Safe to run
141
+ // every startup: a second pass on an already-gone directory is a no-op.
142
+ function cleanupLegacyScreenshots() {
143
+ const screenshotsDir = path.join(process.cwd(), 'reports', 'screenshots');
144
+ if (!fs.existsSync(screenshotsDir)) return;
145
+ fs.rm(screenshotsDir, { recursive: true, force: true }, (err) => {
146
+ if (!err) console.log('🧹 Removed legacy screenshots directory');
147
+ });
148
+ }
149
+
136
150
  function syncAutomatedFlags() {
137
151
  // Sync automated flags from feature files on every startup
138
152
  require('../services/reportService')
@@ -0,0 +1,8 @@
1
+ /*
2
+ This file is part of Plum.
3
+ Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
6
+ Backend running on port 3099 (node/runner mode)
7
+ (node:10858) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
8
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -12,7 +12,6 @@
12
12
  * runs in-process inside the Plum backend, not as a separate subprocess.
13
13
  */
14
14
 
15
- const fs = require('fs');
16
15
  const path = require('path');
17
16
 
18
17
  // Use absolute paths to bypass the SDK's wildcard export mapping
@@ -29,7 +28,6 @@ const { McpServer } = require(path.join(sdkCjs, 'server', 'mcp.js'));
29
28
  const { z } = require('zod');
30
29
  const { TRIGGER_TYPE } = require('../constants/triggers');
31
30
  const { JOB_STATUS } = require('../constants/jobStatus');
32
- const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
33
31
  const testSuiteService = require('../services/testSuiteService');
34
32
  const testCaseService = require('../services/testCaseService');
35
33
  const triggerService = require('../services/triggerService');
@@ -83,13 +81,6 @@ function summariseReport(report) {
83
81
  };
84
82
  }
85
83
 
86
- // ---------------------------------------------------------------------------
87
- // Screenshots
88
- // ---------------------------------------------------------------------------
89
-
90
- const SCREENSHOT_FILENAME_RE = /^[\w.-]+\.(png|jpg|jpeg)$/i;
91
- const SCREENSHOT_MIME_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
92
-
93
84
  // ---------------------------------------------------------------------------
94
85
  // Server setup
95
86
  // ---------------------------------------------------------------------------
@@ -450,11 +441,9 @@ function createMcpServer({ userId }) {
450
441
  'get_report_scenario_detail',
451
442
  [
452
443
  'Get full per-scenario, per-step detail for a Plum test report — the data needed to diagnose',
453
- "and self-heal a failing test: every step's status, duration, full error message, and",
454
- 'screenshot URL (if one was captured for that step).',
444
+ "and self-heal a failing test: every step's status, duration, and full error message.",
455
445
  '',
456
- 'Use get_report_screenshot to fetch the actual screenshot image for a filename returned here,',
457
- 'and get_report_logs for the raw test-run stdout/stderr.'
446
+ 'Use get_report_logs for the raw test-run stdout/stderr.'
458
447
  ].join('\n'),
459
448
  {
460
449
  reportId: z.number().int().describe('Numeric report ID'),
@@ -482,8 +471,7 @@ function createMcpServer({ userId }) {
482
471
  name: st.name,
483
472
  status: st.status,
484
473
  duration: st.duration,
485
- error: st.error ?? null,
486
- screenshot: st.screenshot ?? null
474
+ error: st.error ?? null
487
475
  }))
488
476
  }))
489
477
  );
@@ -494,38 +482,6 @@ function createMcpServer({ userId }) {
494
482
  }
495
483
  );
496
484
 
497
- server.tool(
498
- 'get_report_screenshot',
499
- 'Fetch a screenshot captured during a test step and return it as an image, so it can be viewed ' +
500
- "directly. Get the filename from get_report_scenario_detail's step.screenshot field.",
501
- {
502
- filename: z.string().describe('Screenshot filename, e.g. "3f9c1e2a-....png"')
503
- },
504
- async ({ filename }) => {
505
- if (!SCREENSHOT_FILENAME_RE.test(filename)) {
506
- throw new Error(`Invalid screenshot filename: ${filename}`);
507
- }
508
- const filePath = path.join(SCREENSHOTS_DIR, filename);
509
- let buffer;
510
- try {
511
- buffer = await fs.promises.readFile(filePath);
512
- } catch {
513
- throw new Error(`Screenshot not found: ${filename}`);
514
- }
515
- const ext = filename.split('.').pop().toLowerCase();
516
-
517
- return {
518
- content: [
519
- {
520
- type: 'image',
521
- data: buffer.toString('base64'),
522
- mimeType: SCREENSHOT_MIME_TYPES[ext]
523
- }
524
- ]
525
- };
526
- }
527
- );
528
-
529
485
  server.tool(
530
486
  'get_report_logs',
531
487
  'Get the raw stdout/stderr log output captured during a Plum test run, tagged per runner. ' +