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
@@ -0,0 +1,109 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ -->
5
+
6
+ <script>
7
+ import { createEventDispatcher } from 'svelte';
8
+ import StepKeyword from '$lib/components/ui/StepKeyword.svelte';
9
+ import StepStatusIcon from '$lib/components/ui/StepStatusIcon.svelte';
10
+ import { STEPS_RAIL_HEADING } from '$lib/copy/reports';
11
+
12
+ export let steps = [];
13
+ export let stepTimestamps = [];
14
+ export let currentStepIndex = -1;
15
+
16
+ const dispatch = createEventDispatcher();
17
+ </script>
18
+
19
+ <aside class="steps-rail">
20
+ <div class="steps-rail-header">{STEPS_RAIL_HEADING}</div>
21
+ <ol class="steps-list">
22
+ {#each steps as step, i}
23
+ <li>
24
+ <button
25
+ class="rail-step"
26
+ class:rail-step-active={i === currentStepIndex}
27
+ disabled={stepTimestamps[i] === undefined}
28
+ on:click={() => dispatch('jump', i)}
29
+ >
30
+ <StepStatusIcon status={step.status} />
31
+ <span class="rail-step-text">
32
+ <StepKeyword keyword={step.keyword} />
33
+ <span>{step.name}</span>
34
+ </span>
35
+ </button>
36
+ </li>
37
+ {/each}
38
+ </ol>
39
+ </aside>
40
+
41
+ <style>
42
+ .steps-rail {
43
+ flex-shrink: 0;
44
+ width: 240px;
45
+ display: flex;
46
+ flex-direction: column;
47
+ background: var(--bg-elevated);
48
+ overflow-y: auto;
49
+ }
50
+
51
+ .steps-rail-header {
52
+ flex-shrink: 0;
53
+ padding: 0.7rem 0.9rem 0.5rem;
54
+ font-family: 'JetBrains Mono', monospace;
55
+ font-size: 0.68rem;
56
+ font-weight: 600;
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.06em;
59
+ color: var(--text-muted);
60
+ }
61
+
62
+ .steps-list {
63
+ list-style: none;
64
+ margin: 0;
65
+ padding: 0 0.5rem 0.5rem;
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.1rem;
69
+ }
70
+
71
+ .rail-step {
72
+ width: 100%;
73
+ display: flex;
74
+ align-items: flex-start;
75
+ gap: 0.5rem;
76
+ padding: 0.4rem 0.5rem;
77
+ background: none;
78
+ border: none;
79
+ border-radius: var(--radius-sm);
80
+ font: inherit;
81
+ font-size: 0.78rem;
82
+ line-height: 1.35;
83
+ text-align: left;
84
+ color: var(--text-muted);
85
+ cursor: pointer;
86
+ transition: background var(--duration-fast) var(--ease-out);
87
+ }
88
+ .rail-step:hover:not(:disabled) {
89
+ background: var(--bg-subtle);
90
+ }
91
+ .rail-step:disabled {
92
+ cursor: default;
93
+ }
94
+
95
+ .rail-step-text {
96
+ display: inline-flex;
97
+ flex-wrap: wrap;
98
+ align-items: baseline;
99
+ gap: 0.35rem;
100
+ min-width: 0;
101
+ word-break: break-word;
102
+ }
103
+
104
+ .rail-step-active {
105
+ background: var(--accent-soft);
106
+ color: var(--text);
107
+ font-weight: 500;
108
+ }
109
+ </style>
@@ -0,0 +1,61 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ -->
5
+
6
+ <script>
7
+ export let code = '';
8
+
9
+ function escapeHtml(s) {
10
+ return s
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function highlight(raw) {
19
+ const escaped = escapeHtml(raw);
20
+ return escaped
21
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="tok-comment">$1</span>')
22
+ .replace(/(&lt;\/?)([a-zA-Z][a-zA-Z0-9-]*)/g, '$1<span class="tok-tag">$2</span>')
23
+ .replace(
24
+ /([a-zA-Z_:][a-zA-Z0-9_:.-]*)(=)(&quot;[^&]*&quot;)/g,
25
+ '<span class="tok-attr">$1</span>$2<span class="tok-value">$3</span>'
26
+ );
27
+ }
28
+
29
+ $: highlighted = highlight(code);
30
+ </script>
31
+
32
+ <pre class="code-viewer"><code>{@html highlighted}</code></pre>
33
+
34
+ <style>
35
+ .code-viewer {
36
+ margin: 0;
37
+ padding: 0.75rem 1rem;
38
+ background: var(--terminal-bg);
39
+ color: var(--terminal-text);
40
+ font-family: 'JetBrains Mono', monospace;
41
+ font-size: 0.78rem;
42
+ line-height: 1.5;
43
+ border-radius: var(--radius-md);
44
+ overflow-x: auto;
45
+ white-space: pre-wrap;
46
+ word-break: break-word;
47
+ }
48
+ :global(.code-viewer .tok-tag) {
49
+ color: var(--code-tag);
50
+ }
51
+ :global(.code-viewer .tok-attr) {
52
+ color: var(--code-attr);
53
+ }
54
+ :global(.code-viewer .tok-value) {
55
+ color: var(--code-string);
56
+ }
57
+ :global(.code-viewer .tok-comment) {
58
+ color: var(--code-comment);
59
+ font-style: italic;
60
+ }
61
+ </style>
@@ -27,7 +27,6 @@ export const SUITE_CASES_PER_PAGE = 20;
27
27
  export const COPY_TIMEOUT_MS = 1400;
28
28
  export const TOAST_TIMEOUT_MS = 4000;
29
29
 
30
- export const REPLAY_STEP_MS = 900;
31
30
  export const REDIRECT_DELAY_MS = 3000;
32
31
 
33
32
  export const WORKERS_MIN = 1;
@@ -18,6 +18,9 @@ export const TREND_HINT = '← older · newer →';
18
18
  export const NO_REPORTS_MESSAGE = 'No reports yet. Run a test to generate one.';
19
19
  export const SELECT_ALL_TITLE = 'Select all on this page';
20
20
  export const SELECT_ROW_TITLE = 'Select';
21
+ export const LEGACY_SCREENSHOTS_NOTICE =
22
+ 'Screenshots have been replaced by full session replay. Reports created before this change no longer have screenshots — steps and logs are still available.';
23
+ export const DISMISS_NOTICE_TITLE = 'Dismiss';
21
24
  export const DELETE_REPORT_TITLE = 'Delete report';
22
25
 
23
26
  export const deleteReportsTitle = (count) =>
@@ -43,17 +46,23 @@ export const RUN_LOGS_LABEL = 'Run Logs';
43
46
  export const RETRY_TITLE = 'Failed and was automatically retried before the final result';
44
47
  export const WATCH_REPLAY_TITLE = 'Watch replay';
45
48
  export const REPLAY_LABEL = 'Replay';
46
- export const SCREENSHOT_TOGGLE_LABEL = 'Screenshot';
47
- export const STEP_SCREENSHOT_ALT = 'Step screenshot';
48
- export const NO_SCREENSHOT_MESSAGE = 'No screenshot captured for this step';
49
49
 
50
50
  export const runnersBadge = (count) => `${count} runners`;
51
51
  export const casesCountLabel = (count) => `${count} cases`;
52
52
  export const attemptsLabel = (count) => `${count} attempts`;
53
53
  export const caseLabel = (index) => `Case ${index}`;
54
- export const replayScreenshotAlt = (index) => `Step ${index} screenshot`;
55
- export const pauseOrPlayTitle = (playing) => (playing ? 'Pause' : 'Play');
56
- export const replayCounter = (index, total) => `${index} / ${total}`;
54
+
55
+ // ── Recording replay ──
56
+ export const PLAYER_LOAD_ERROR = 'Could not load this recording.';
57
+ export const INSPECT_TOGGLE_LABEL = 'Inspect element';
58
+ export const RESTART_LABEL = 'Restart replay';
59
+ export const STEPS_RAIL_HEADING = 'Steps';
60
+ export const INSPECTOR_HEADING = 'Inspector';
61
+ export const NO_ELEMENT_SELECTED = 'Click an element in the replay to inspect it.';
62
+ export const ELEMENT_ATTRIBUTES_LABEL = 'Attributes';
63
+ export const ELEMENT_SIZE_LABEL = 'Size';
64
+ export const recordingTabLabel = (tabIndex) =>
65
+ tabIndex === 0 ? 'Main tab' : `Tab/Window ${tabIndex + 1}`;
57
66
 
58
67
  // ── Live run ──
59
68
  export const LIVE_PAGE_TITLE = 'Live Run — Plum';
@@ -68,8 +77,6 @@ export const CANCEL_RUN_LABEL = 'Cancel run';
68
77
  export const ALL_TESTS_PASSED = 'All tests passed';
69
78
  export const SOME_TESTS_FAILED = 'Some tests failed';
70
79
  export const VIEW_REPORT_NOW_LABEL = 'View Report Now';
71
- export const LIVE_STEP_LABEL = 'Step';
72
- export const LIVE_BROWSER_VIEW_ALT = 'Live browser view';
73
80
  export const AWAITING_STREAM_LABEL = 'Awaiting stream...';
74
81
  export const NO_STREAM_LABEL = 'No stream...';
75
82
  export const RUNNER_LABEL = 'Runner';
@@ -77,6 +84,9 @@ export const RUNNING_LABEL = 'Running…';
77
84
  export const FINISHED_LABEL = 'Finished';
78
85
  export const WAITING_FOR_OUTPUT = '(waiting for output…)';
79
86
 
87
+ export const UNKNOWN_RUNNER_LABEL = 'Unknown runner';
88
+ export const workerLabel = (id) => `Worker ${id}`;
89
+
80
90
  export const runsInProgressHeading = (count) =>
81
91
  count === 1 ? 'A run in progress' : `${count} runs in progress`;
82
92
  export const workersCountLabel = (count) => `${count} ${pluralize(count, 'worker')}`;
@@ -98,14 +98,20 @@ export const FAILED_TO_DELETE_CASE = 'Failed to delete case.';
98
98
  export const TEST_CASE_UPDATED_TOAST = 'Test case updated.';
99
99
  export const STEPS_SAVED_TOAST = 'Steps saved.';
100
100
  export const SUITE_UPDATED_TOAST = 'Suite updated.';
101
+ export const MOVE_CASE_TITLE = 'Move to suite';
102
+ export const MOVE_TEST_CASE_MODAL_TITLE = 'Move Test Case';
103
+ export const MOVE_TO_SUITE_LABEL = 'Move to suite';
104
+ export const FAILED_TO_MOVE_CASE = 'Failed to move case.';
101
105
 
102
106
  export const suiteDetailTitle = (suite) =>
103
107
  `${suite ? `${suite.displayId} — ${suite.name}` : 'Suite'} — Plum`;
104
108
  export const createdByCapitalized = (name) => `Created by ${name}`;
105
109
  export const caseCreatedToast = (displayId) => `${displayId} created.`;
106
110
  export const caseDeletedToast = (displayId) => `${displayId} deleted.`;
111
+ export const caseMovedToast = (displayId, suiteName) => `${displayId} moved to ${suiteName}.`;
107
112
  export const stepCount = (count) => `${count} ${pluralize(count, 'step')}`;
108
113
  export const saveStepsLabel = (saving) => (saving ? 'Saving…' : 'Save Steps');
114
+ export const moveCaseLabel = (saving) => (saving ? 'Moving…' : 'Move');
109
115
  export const runLinkLabel = (title) => `${title} ↗`;
110
116
 
111
117
  // ── Run detail ──
@@ -141,7 +141,7 @@ export const SLACK_WEBHOOK_HINT = 'Leave blank to disable Slack notifications';
141
141
  export const SLACK_WEBHOOK_PLACEHOLDER = 'https://hooks.slack.com/services/…';
142
142
  export const PUBLIC_URL_LABEL = 'Public URL';
143
143
  export const PUBLIC_URL_HINT =
144
- 'Base URL of this Plum instance, used to link reports in notifications';
144
+ 'Base URL of this Plum instance used to link reports in notifications, and by remote runner nodes to stream live test output back here';
145
145
  export const PUBLIC_URL_PLACEHOLDER = 'https://plum.yourcompany.com';
146
146
  export const INTEGRATIONS_SAVED_TOAST = 'Integration settings saved.';
147
147
  export const INTEGRATIONS_SAVE_FAILED = 'Failed to save integration settings.';
@@ -225,9 +225,14 @@ export const IMPORT_BLOCK_TITLE = 'Import';
225
225
  export const IMPORT_BLOCK_DESC =
226
226
  'Restores all data from a previously exported backup. Existing records are overwritten. Cron jobs are re-scheduled after import.';
227
227
  export const CHOOSE_FILE_LABEL = 'Choose file…';
228
- export const BACKUP_DISCLAIMER_PREFIX =
229
- 'Reports are not included in backups. To back up report history, run';
230
- export const BACKUP_DISCLAIMER_SUFFIX = 'directly on the PostgreSQL volume.';
228
+ export const INCLUDE_REPORTS_LABEL = 'Include reports & recordings';
229
+ export const INCLUDE_REPORTS_HINT =
230
+ 'Applies to both manual export and scheduled S3 backups. Can make backups significantly larger — recordings are session replays, not just screenshots.';
231
+ export const includeReportsDisclaimer = (included) =>
232
+ included
233
+ ? 'Reports and recordings are included in backups.'
234
+ : 'Reports are not included in backups — enable "Include reports & recordings" above, or run pg_dump directly on the PostgreSQL volume, to back up report history.';
235
+ export const saveIncludeReportsLabel = (saving) => (saving ? SAVING_LABEL : SAVE_LABEL);
231
236
 
232
237
  export const S3_STORAGE_CARD_TITLE = 'S3 Storage';
233
238
  export const S3_STORAGE_DESC_PREFIX =
@@ -261,6 +266,19 @@ const SECRET_KEY_SET_PLACEHOLDER = '••••••••';
261
266
  const SECRET_KEY_UNSET_PLACEHOLDER = 'Enter secret key';
262
267
  const TESTING_LABEL = 'Testing…';
263
268
 
269
+ export const RESTORE_FROM_S3_CARD_TITLE = 'Restore from S3';
270
+ export const RESTORE_FROM_S3_DESC =
271
+ 'Restores directly from a backup already uploaded to S3 — no need to download it yourself first. Existing records are overwritten. Cron jobs are re-scheduled after restore.';
272
+ export const CONFIGURE_S3_FIRST_RESTORE_MESSAGE = 'Configure S3 storage above to restore from it.';
273
+ export const NO_S3_BACKUPS_MESSAGE = 'No backups found at this bucket/prefix.';
274
+ export const REFRESH_LABEL = 'Refresh';
275
+ export const RESTORE_CONFIRM_TITLE = 'Restore this backup?';
276
+ export const restoreConfirmBody = (key) =>
277
+ `This will overwrite current cron jobs, test cases, test runs, users, runners, and project settings with the contents of "${key}". This cannot be undone.`;
278
+ export const RESTORE_SUCCESS_TOAST = 'Restored from S3 successfully.';
279
+ export const RESTORE_FAILED_FALLBACK = 'Restore failed.';
280
+ export const LIST_S3_BACKUPS_FAILED = 'Failed to list S3 backups.';
281
+
264
282
  export const SCHEDULED_BACKUP_CARD_TITLE = 'Scheduled Backup';
265
283
  export const CONFIGURE_S3_FIRST_MESSAGE = 'Configure S3 storage above to enable scheduled backups.';
266
284
  export const ENABLE_SCHEDULED_BACKUP_LABEL = 'Enable scheduled backup';
@@ -293,3 +311,6 @@ export const saveS3ConfigLabel = (saving) => (saving ? SAVING_LABEL : SAVE_S3_CO
293
311
  export const uploadedToLabel = (destination) => `uploaded to ${destination}`;
294
312
  export const uploadS3NowLabel = (running) => (running ? UPLOADING_LABEL : UPLOAD_TO_S3_NOW_LABEL);
295
313
  export const saveScheduleLabel = (saving) => (saving ? SAVING_LABEL : SAVE_SCHEDULE_LABEL);
314
+ export const restoreLabel = (restoring) => (restoring ? 'Restoring…' : 'Restore');
315
+ export const refreshingLabel = (loading) => (loading ? 'Loading…' : REFRESH_LABEL);
316
+ export const backupSizeLabel = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
@@ -13,23 +13,26 @@ export const SOCKET_EVENTS = Object.freeze({
13
13
  CANCEL_TEST: 'cancel-test',
14
14
  LOG: 'log',
15
15
  DONE: 'done',
16
- STEP_SCREENSHOT: 'step-screenshot',
17
16
 
18
17
  // Multi-lane distributed run (single interactive run, several runners)
19
18
  RUNNER_LANES_INIT: 'runner-lanes-init',
20
19
  RUNNER_LANE_LOG: 'runner-lane-log',
21
20
  RUNNER_LANE_STATUS: 'runner-lane-status',
22
- RUNNER_LANE_SCREENSHOT: 'runner-lane-screenshot',
23
21
 
24
22
  // Background runs (cron / REST / MCP triggered, no single owning socket)
25
23
  BG_RUN_START: 'bg-run-start',
26
24
  BG_RUN_LOG: 'bg-run-log',
27
25
  BG_RUN_DONE: 'bg-run-done',
28
- BG_RUN_SCREENSHOT: 'bg-run-screenshot',
29
26
  BG_RUN_LANES_INIT: 'bg-run-lanes-init',
30
27
  BG_RUN_LANE_LOG: 'bg-run-lane-log',
31
28
  BG_RUN_LANE_STATUS: 'bg-run-lane-status',
32
- BG_RUN_LANE_SCREENSHOT: 'bg-run-lane-screenshot',
29
+
30
+ // Live rrweb streaming — one shape for every run type, always
31
+ // carrying a lane id (BUILT_IN_RUNNER_ID for the plain single-run case) and
32
+ // a workerId, so a single built-in run with --parallel workers is finally
33
+ // attributable per worker instead of one flat interleaved stream.
34
+ RUNNER_LANE_RRWEB_BATCH: 'runner-lane-rrweb-batch',
35
+ BG_RUN_LANE_RRWEB_BATCH: 'bg-run-lane-rrweb-batch',
33
36
 
34
37
  // Global notifications (any client, not tied to a specific run)
35
38
  REPORT_READY: 'report-ready'
@@ -17,11 +17,34 @@ export const runnerState = writable({
17
17
  latestReportId: null, // number | null — set after test finishes
18
18
  status: 'idle', // 'idle' | 'running' | 'pass' | 'fail'
19
19
  lastRunId: '',
20
- lanes: [], // [{ id, name, testCount, status, logs, latestScreenshot }] multi-runner only
20
+ lanes: [], // [{ id, name, testCount, status, logs }] multi-runner only
21
21
  currentRun: null, // { tag, workers, browser, runners } — set while running
22
- latestScreenshot: null // { stepName, data: base64 } for single built-in runner
22
+ // { [laneId]: { [workerId]: { events: [] } } } always keyed by laneId even
23
+ // for a plain single-runner run (BUILT_IN_RUNNER_ID), so the live view's
24
+ // Runner/Worker tabs don't need a separate code path for that case.
25
+ rrwebByLane: {}
23
26
  });
24
27
 
28
+ // Merges a batch of rrweb events into the right lane/worker bucket, creating
29
+ // it on first sight — Svelte only re-renders on a *new* object/array
30
+ // reference, so this rebuilds the path down to the mutated bucket rather than
31
+ // pushing in place. Shared by runnerState (interactive) and backgroundRuns.
32
+ export function mergeRRwebBatch(rrwebByLane, { id: laneId, workerId, events }) {
33
+ const lane = rrwebByLane[laneId] ?? {};
34
+ const worker = lane[workerId] ?? { events: [] };
35
+ return {
36
+ ...rrwebByLane,
37
+ [laneId]: {
38
+ ...lane,
39
+ [workerId]: { events: [...worker.events, ...events] }
40
+ }
41
+ };
42
+ }
43
+
44
+ export function appendRRwebBatch(batch) {
45
+ runnerState.update((s) => ({ ...s, rrwebByLane: mergeRRwebBatch(s.rrwebByLane, batch) }));
46
+ }
47
+
25
48
  export const runnerConfig = writable({
26
49
  workers: 1,
27
50
  testID: '',
@@ -58,7 +81,7 @@ export function triggerRun(id, testRunId, notify = {}, runTitle = null) {
58
81
  lastRunId: runId,
59
82
  lanes: [],
60
83
  currentRun: { tag: runId, workers, browser, runners: selectedRunners, runTitle },
61
- latestScreenshot: null
84
+ rrwebByLane: {}
62
85
  });
63
86
  panelExpanded.set(true);
64
87
 
@@ -26,6 +26,13 @@
26
26
  --external-soft: #ccfbf1;
27
27
  --terminal-bg: #0d0c08;
28
28
  --terminal-text: #d6d0c8;
29
+ /* Fixed syntax-highlight colors for the always-dark code viewer (inspector
30
+ markup panel) — deliberately NOT the theme accent/node/pass tokens above,
31
+ which are tuned for light surfaces and go muddy against a dark one. */
32
+ --code-tag: #7dd3fc;
33
+ --code-attr: #fbbf24;
34
+ --code-string: #86efac;
35
+ --code-comment: #9ca3af;
29
36
 
30
37
  --font-display: 'Playfair Display', Georgia, serif;
31
38
  --font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
@@ -137,6 +137,112 @@ export function parseRunnerLogs(logs) {
137
137
  return sections;
138
138
  }
139
139
 
140
- export function scenarioHasScreenshots(scenario) {
141
- return scenario.steps?.some((step) => step.screenshot) ?? false;
140
+ /**
141
+ * Buckets a report's features/scenarios by runner (lane) then worker
142
+ * (Cucumber --parallel process), preserving first-seen order. The innermost
143
+ * `features` array keeps the same shape as the flat list it replaces —
144
+ * callers should only show a group header when a level has more than one
145
+ * bucket.
146
+ */
147
+ export function groupScenariosByRunnerAndWorker(features) {
148
+ const runnerOrder = [];
149
+ const runnerMap = new Map();
150
+
151
+ for (const feature of features ?? []) {
152
+ for (const scenario of feature.scenarios ?? []) {
153
+ const runnerKey = scenario.runnerName ?? '';
154
+ const workerKey = scenario.workerId ?? 1;
155
+
156
+ if (!runnerMap.has(runnerKey)) {
157
+ runnerMap.set(runnerKey, {
158
+ runnerName: scenario.runnerName ?? null,
159
+ workerOrder: [],
160
+ workerMap: new Map()
161
+ });
162
+ runnerOrder.push(runnerKey);
163
+ }
164
+ const runnerEntry = runnerMap.get(runnerKey);
165
+
166
+ if (!runnerEntry.workerMap.has(workerKey)) {
167
+ runnerEntry.workerMap.set(workerKey, {
168
+ workerId: workerKey,
169
+ featureOrder: [],
170
+ featureMap: new Map()
171
+ });
172
+ runnerEntry.workerOrder.push(workerKey);
173
+ }
174
+ const workerEntry = runnerEntry.workerMap.get(workerKey);
175
+
176
+ if (!workerEntry.featureMap.has(feature.name)) {
177
+ workerEntry.featureMap.set(feature.name, { ...feature, scenarios: [] });
178
+ workerEntry.featureOrder.push(feature.name);
179
+ }
180
+ workerEntry.featureMap.get(feature.name).scenarios.push(scenario);
181
+ }
182
+ }
183
+
184
+ return runnerOrder.map((rk) => {
185
+ const r = runnerMap.get(rk);
186
+ return {
187
+ runnerName: r.runnerName,
188
+ workers: r.workerOrder.map((wk) => {
189
+ const w = r.workerMap.get(wk);
190
+ return {
191
+ workerId: w.workerId,
192
+ features: w.featureOrder.map((fn) => {
193
+ const f = w.featureMap.get(fn);
194
+ return { ...f, scenarioGroups: groupedScenarios(f.scenarios) };
195
+ })
196
+ };
197
+ })
198
+ };
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Turns a scenario's tab recordings into an ordered, non-overlapping timeline
204
+ * of {recordingId, from, to} segments (epoch ms), so a replay can auto-switch
205
+ * which tab it's showing instead of a manual tab strip. Recordings missing
206
+ * startedAt/endedAt (written before that field existed) are dropped — nothing
207
+ * to line up on a shared clock without them.
208
+ *
209
+ * Tabs are assumed to open/close like a stack (a popup nested inside the tab
210
+ * that opened it) — the only shape browser.ts's own tab tracking produces.
211
+ */
212
+ export function computeRecordingSegments(recordings) {
213
+ const usable = recordings.filter((r) => r.startedAt != null && r.endedAt != null);
214
+ if (usable.length === 0) return [];
215
+
216
+ const boundaries = usable.flatMap((r) => [
217
+ { ts: r.startedAt, kind: 'open', recording: r },
218
+ { ts: r.endedAt, kind: 'close', recording: r }
219
+ ]);
220
+ boundaries.sort((a, b) => a.ts - b.ts || (a.kind === 'open' ? -1 : 1));
221
+
222
+ const segments = [];
223
+ const stack = [];
224
+ let lastBoundary = boundaries[0].ts;
225
+
226
+ for (const b of boundaries) {
227
+ if (b.kind === 'open') {
228
+ const top = stack[stack.length - 1];
229
+ if (top && b.ts > lastBoundary) {
230
+ segments.push({ recordingId: top.id, from: lastBoundary, to: b.ts });
231
+ }
232
+ stack.push(b.recording);
233
+ lastBoundary = b.ts;
234
+ } else if (stack[stack.length - 1]?.id === b.recording.id) {
235
+ segments.push({ recordingId: b.recording.id, from: lastBoundary, to: b.ts });
236
+ stack.pop();
237
+ lastBoundary = b.ts;
238
+ }
239
+ // A close for a recording that isn't currently on top means it opened
240
+ // and closed while something nested inside it was still active — its
241
+ // own segment stays open until whatever's on top of it closes too.
242
+ }
243
+ const remaining = stack[stack.length - 1];
244
+ if (remaining && remaining.endedAt > lastBoundary) {
245
+ segments.push({ recordingId: remaining.id, from: lastBoundary, to: remaining.endedAt });
246
+ }
247
+ return segments;
142
248
  }
@@ -0,0 +1,34 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+
6
+ function escapeAttr(v) {
7
+ return v.replace(/"/g, '&quot;');
8
+ }
9
+
10
+ function shallowMarkup(el) {
11
+ const tag = el.tagName.toLowerCase();
12
+ const attrs = Array.from(el.attributes ?? [])
13
+ .map((a) => ` ${a.name}="${escapeAttr(a.value)}"`)
14
+ .join('');
15
+ const childCount = el.children.length;
16
+ const text = childCount === 0 ? (el.textContent ?? '').trim() : '';
17
+ const inner =
18
+ childCount > 0
19
+ ? `\n <!-- ${childCount} child element${childCount === 1 ? '' : 's'} -->\n`
20
+ : text
21
+ ? `\n ${text.slice(0, 200)}\n`
22
+ : '';
23
+ return `<${tag}${attrs}>${inner}</${tag}>`;
24
+ }
25
+
26
+ /** Summarizes a DOM element for the replay inspector panel: markup preview, attributes, size. */
27
+ export function describeElement(el) {
28
+ const rect = el.getBoundingClientRect();
29
+ return {
30
+ markup: shallowMarkup(el),
31
+ attributes: Array.from(el.attributes ?? []).map((a) => ({ name: a.name, value: a.value })),
32
+ box: { width: Math.round(rect.width), height: Math.round(rect.height) }
33
+ };
34
+ }
@@ -242,7 +242,7 @@
242
242
  {@const suiteKey = suiteIds(suite)[0] ?? suite.suiteName}
243
243
  {@const suiteOpen = !collapsedSuites.has(suiteKey)}
244
244
  <div class="suite" style={stagger(si, 55)}>
245
- <div class="suite-header">
245
+ <div class="suite-header" class:collapsed={!suiteOpen}>
246
246
  <button
247
247
  class="suite-toggle"
248
248
  on:click={() => toggleSuite(suiteKey)}
@@ -572,6 +572,11 @@
572
572
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
573
573
  }
574
574
 
575
+ .suite-header.collapsed {
576
+ border-bottom: none;
577
+ border-radius: var(--radius-lg);
578
+ }
579
+
575
580
  .suite-toggle {
576
581
  display: flex;
577
582
  align-items: center;