@shipfox/client-workflows 14.0.1 → 16.0.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 (272) hide show
  1. package/.storybook/preview.css +4 -0
  2. package/.storybook/preview.tsx +7 -2
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +42 -0
  5. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  6. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  7. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts +15 -0
  9. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  10. package/dist/components/job-detail/job-detail-header.js +124 -0
  11. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  13. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  14. package/dist/components/job-detail/job-detail-view.js +420 -0
  15. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  17. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  18. package/dist/components/job-detail/job-empty-states.js +195 -0
  19. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  21. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  23. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  24. package/dist/components/job-detail/job-selection.d.ts +24 -0
  25. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  26. package/dist/components/job-detail/job-selection.js +66 -0
  27. package/dist/components/job-detail/job-selection.js.map +1 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  29. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  30. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  31. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  32. package/dist/components/job-graph/job-graph-content.js +1 -1
  33. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  34. package/dist/components/step-list/step-list-model.js +2 -2
  35. package/dist/components/step-list/step-list-model.js.map +1 -1
  36. package/dist/components/step-list/step-list.d.ts +1 -0
  37. package/dist/components/step-list/step-list.d.ts.map +1 -1
  38. package/dist/components/step-list/step-list.js +29 -4
  39. package/dist/components/step-list/step-list.js.map +1 -1
  40. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  41. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  42. package/dist/components/workflow-run-duration-label.js +27 -7
  43. package/dist/components/workflow-run-duration-label.js.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  45. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  46. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js +69 -64
  49. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  50. package/dist/components/workflow-run-tabs/index.d.ts +3 -3
  51. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  52. package/dist/components/workflow-run-tabs/index.js +3 -3
  53. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  54. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
  55. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
  56. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
  57. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
  58. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
  59. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
  60. package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
  61. package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
  62. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
  63. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
  64. package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
  65. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
  66. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
  67. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  68. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
  69. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  70. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  71. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  72. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  73. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  74. package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
  75. package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
  76. package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
  77. package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
  78. package/dist/components/workflow-run-view/index.d.ts +0 -1
  79. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  80. package/dist/components/workflow-run-view/index.js +0 -1
  81. package/dist/components/workflow-run-view/index.js.map +1 -1
  82. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  83. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  84. package/dist/components/workflow-run-view/run-workspace-nav.js +277 -0
  85. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  86. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  87. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  88. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  89. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -10
  90. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  91. package/dist/components/workflow-run-view/workflow-run-view.js +351 -239
  92. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  93. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  94. package/dist/components/workflow-status/status-visuals.js +10 -2
  95. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  96. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  97. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  98. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  99. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  100. package/dist/core/entities/job-execution.d.ts +9 -0
  101. package/dist/core/entities/job-execution.d.ts.map +1 -1
  102. package/dist/core/entities/job-execution.js +13 -3
  103. package/dist/core/entities/job-execution.js.map +1 -1
  104. package/dist/core/entities/job.d.ts.map +1 -1
  105. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  106. package/dist/core/entities/step.d.ts +2 -0
  107. package/dist/core/entities/step.d.ts.map +1 -1
  108. package/dist/core/entities/step.js +13 -0
  109. package/dist/core/entities/step.js.map +1 -1
  110. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  111. package/dist/core/entities/workflow-run.d.ts +56 -1
  112. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  113. package/dist/core/entities/workflow-run.js +98 -0
  114. package/dist/core/entities/workflow-run.js.map +1 -1
  115. package/dist/core/run-annotation.d.ts +73 -0
  116. package/dist/core/run-annotation.d.ts.map +1 -0
  117. package/dist/core/run-annotation.js +125 -0
  118. package/dist/core/run-annotation.js.map +1 -0
  119. package/dist/core/workflow-run.d.ts +4 -4
  120. package/dist/core/workflow-run.d.ts.map +1 -1
  121. package/dist/core/workflow-run.js +3 -3
  122. package/dist/core/workflow-run.js.map +1 -1
  123. package/dist/feature.d.ts +20 -16
  124. package/dist/feature.d.ts.map +1 -1
  125. package/dist/feature.js +5 -0
  126. package/dist/feature.js.map +1 -1
  127. package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
  128. package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
  129. package/dist/hooks/api/run-annotation-mapper.js +22 -0
  130. package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
  131. package/dist/hooks/api/run-annotations.d.ts +25 -0
  132. package/dist/hooks/api/run-annotations.d.ts.map +1 -0
  133. package/dist/hooks/api/run-annotations.js +78 -0
  134. package/dist/hooks/api/run-annotations.js.map +1 -0
  135. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  136. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  137. package/dist/hooks/api/workflow-runs.js +5 -4
  138. package/dist/hooks/api/workflow-runs.js.map +1 -1
  139. package/dist/index.d.ts +1 -0
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +1 -0
  142. package/dist/index.js.map +1 -1
  143. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  144. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  145. package/dist/pages/workflow-job-detail-page.js +66 -0
  146. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  147. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  148. package/dist/pages/workflow-run-detail-page.js +4 -37
  149. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  150. package/dist/routes/inputs.d.ts +52 -34
  151. package/dist/routes/inputs.d.ts.map +1 -1
  152. package/dist/routes/inputs.js +74 -43
  153. package/dist/routes/inputs.js.map +1 -1
  154. package/dist/routes/job-detail.d.ts +10 -0
  155. package/dist/routes/job-detail.d.ts.map +1 -0
  156. package/dist/routes/job-detail.js +27 -0
  157. package/dist/routes/job-detail.js.map +1 -0
  158. package/dist/routes/project-route.d.ts +1 -1
  159. package/dist/routes/run-detail.d.ts.map +1 -1
  160. package/dist/routes/runs.d.ts.map +1 -1
  161. package/dist/routes/workflows.d.ts.map +1 -1
  162. package/dist/tsconfig.test.tsbuildinfo +1 -1
  163. package/package.json +10 -9
  164. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  165. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  166. package/src/components/job-detail/job-detail-header.test.tsx +34 -0
  167. package/src/components/job-detail/job-detail-header.tsx +136 -0
  168. package/src/components/job-detail/job-detail-view.stories.tsx +531 -0
  169. package/src/components/job-detail/job-detail-view.tsx +467 -0
  170. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  171. package/src/components/job-detail/job-empty-states.tsx +248 -0
  172. package/src/components/job-detail/job-selection.test.ts +188 -0
  173. package/src/components/job-detail/job-selection.ts +96 -0
  174. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  175. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  176. package/src/components/job-graph/job-graph-content.tsx +1 -1
  177. package/src/components/step-list/step-list-model.test.ts +1 -1
  178. package/src/components/step-list/step-list-model.ts +2 -2
  179. package/src/components/step-list/step-list.stories.tsx +1 -1
  180. package/src/components/step-list/step-list.test.tsx +33 -0
  181. package/src/components/step-list/step-list.tsx +40 -9
  182. package/src/components/workflow-run-duration-label.tsx +50 -12
  183. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  184. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  185. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  186. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  187. package/src/components/workflow-run-tabs/index.ts +3 -3
  188. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
  189. package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
  190. package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
  191. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
  192. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
  193. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  194. package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
  195. package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
  196. package/src/components/workflow-run-view/index.ts +0 -1
  197. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +286 -0
  198. package/src/components/workflow-run-view/run-workspace-nav.tsx +314 -0
  199. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  200. package/src/components/workflow-run-view/workflow-run-view.test.tsx +237 -1085
  201. package/src/components/workflow-run-view/workflow-run-view.tsx +450 -323
  202. package/src/components/workflow-status/status-visuals.ts +5 -2
  203. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  204. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  205. package/src/core/entities/job-execution.ts +17 -2
  206. package/src/core/entities/step.ts +26 -0
  207. package/src/core/entities/workflow-run-display.test.ts +159 -0
  208. package/src/core/entities/workflow-run.ts +139 -1
  209. package/src/core/run-annotation.test.ts +315 -0
  210. package/src/core/run-annotation.ts +238 -0
  211. package/src/core/workflow-run.ts +16 -1
  212. package/src/feature.ts +5 -0
  213. package/src/hooks/api/run-annotation-mapper.ts +30 -0
  214. package/src/hooks/api/run-annotations.ts +137 -0
  215. package/src/hooks/api/workflow-runs.ts +6 -2
  216. package/src/index.ts +1 -0
  217. package/src/page-harness-budget.test.ts +1 -0
  218. package/src/pages/workflow-job-detail-page.test.tsx +605 -0
  219. package/src/pages/workflow-job-detail-page.tsx +84 -0
  220. package/src/pages/workflow-run-detail-page.tsx +3 -43
  221. package/src/pages/workflow-run-pages.test.tsx +77 -332
  222. package/src/routes/inputs.test.ts +56 -6
  223. package/src/routes/inputs.ts +71 -19
  224. package/src/routes/job-detail.tsx +29 -0
  225. package/test/pages.tsx +25 -3
  226. package/test/render.tsx +6 -1
  227. package/tsconfig.build.tsbuildinfo +1 -1
  228. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  229. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  230. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  231. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  232. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  233. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  234. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  235. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  236. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  237. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  238. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  239. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  240. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  241. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  242. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  243. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  244. package/dist/components/workflow-run-view/job-card.js +0 -496
  245. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  246. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  247. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  248. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  249. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  250. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  251. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  252. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  253. package/dist/core/workflow-run-tabs.d.ts +0 -9
  254. package/dist/core/workflow-run-tabs.d.ts.map +0 -1
  255. package/dist/core/workflow-run-tabs.js +0 -3
  256. package/dist/core/workflow-run-tabs.js.map +0 -1
  257. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  258. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  259. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  260. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  261. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  262. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  263. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  264. package/src/components/workflow-run-view/job-card.tsx +0 -614
  265. package/src/core/workflow-run-tabs.ts +0 -8
  266. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  267. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  268. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  269. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  270. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  271. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  272. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -1,17 +1,12 @@
1
+ import type {AnnotationDto} from '@shipfox/annotations-dto';
1
2
  import type {WorkflowRunDetailResponseDto} from '@shipfox/api-workflows-dto';
2
3
  import {configureApiClient} from '@shipfox/client-api';
3
- import {toast} from '@shipfox/react-ui/toast';
4
4
  import {screen, waitFor, within} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
- import {workflowRunsQueryKeys} from '#hooks/api/workflow-runs.js';
7
- import {inlineLogBody, outputLine} from '#test/fixtures/logs.js';
8
6
  import {
9
- runAttemptsResponseDto,
10
7
  workflowJobDto,
11
8
  workflowJobExecutionDto,
12
- workflowRunAttemptDto,
13
9
  workflowRunDetailDto,
14
- workflowRunDto,
15
10
  workflowStepAttemptDto,
16
11
  workflowStepDto,
17
12
  } from '#test/fixtures/workflow-run.js';
@@ -20,700 +15,275 @@ import {WorkflowRunView} from './workflow-run-view.js';
20
15
 
21
16
  const RUN_ID = '66666666-6666-4666-8666-666666666666';
22
17
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
23
- const DEFINITION_ID = '55555555-5555-4555-8555-555555555555';
24
18
  const BUILD_JOB_ID = '77777777-7777-4777-8777-777777777777';
25
19
  const DEPLOY_JOB_ID = '88888888-8888-4888-8888-888888888888';
26
- const CHECKOUT_STEP_ID = '99999999-9999-4999-8999-999999999999';
27
- const CHECKOUT_ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
28
- const RERUN_BUTTON_NAME = /^Re-run/;
29
- const BUILD_JOB_BUTTON_NAME = 'build, Succeeded';
30
- const DEPLOY_JOB_BUTTON_NAME = 'deploy, Running';
31
- const ATTEMPT_2_PATTERN = /Attempt 2/;
32
- const COPY_RUN_BUTTON_NAME = /Copy run/;
33
- const EXECUTION_ONE_MENU_ITEM_PATTERN = /#1/;
34
- const LISTENING_EXECUTION_SWITCHER_NAME =
35
- 'Switch job execution, currently execution 2: Deployment status event';
36
- const JOB_TEMPLATE_NAME = ['Implement $', '{{ event.issue.identifier }}'].join('');
20
+ const BUILD_EXECUTION_ID = '99999999-9999-4999-8999-000000000001';
21
+ const BUILD_STEP_ID = '55555555-5555-4555-8555-000000000001';
22
+ const BUILD_ATTEMPT_ID = '66666666-6666-4666-8666-000000000001';
23
+ const ANNOTATION_ID_ONE = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000001';
24
+ const ANNOTATION_ID_TWO = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000002';
25
+ const TASK_NINE_PATTERN = /Task nine/;
26
+ const SHOW_MORE_PATTERN = /Show \d+ more/;
37
27
 
38
28
  describe('WorkflowRunView', () => {
39
- test('keeps the real tab strip visible while the run is loading', async () => {
29
+ beforeEach(() => {
30
+ Element.prototype.scrollIntoView = vi.fn();
31
+ });
32
+
33
+ test('reserves the shared run workspace while the run is loading', async () => {
40
34
  configureApiClient({fetchImpl: vi.fn(() => new Promise<Response>(() => undefined))});
41
35
 
42
- renderView({tab: 'summary'});
36
+ renderView();
43
37
 
44
- expect(await screen.findByRole('tab', {name: 'Summary'})).toBeInTheDocument();
45
- expect(screen.getByRole('tab', {name: 'Jobs'})).toBeInTheDocument();
46
- expect(screen.getByRole('tab', {name: 'Annotations'})).toBeInTheDocument();
47
- expect(screen.getByRole('tab', {name: 'Source'})).toBeInTheDocument();
38
+ expect(await screen.findByRole('region', {name: 'Loading workflow run'})).toBeInTheDocument();
39
+ expect(screen.getByLabelText('Loading run navigation')).toBeInTheDocument();
48
40
  expect(screen.getByRole('region', {name: 'Loading workflow run content'})).toBeInTheDocument();
41
+ expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
49
42
  });
50
43
 
51
- test('renders the run summary, jobs list, and selected job step attempts when a run loads', async () => {
52
- configureApiClient({
53
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
54
- });
44
+ test('opens the all-jobs Summary on the dependency graph by default', async () => {
45
+ configureRunFetch();
55
46
 
56
- renderView();
47
+ const {container} = renderView();
57
48
 
58
49
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
59
-
60
50
  expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
61
- expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
62
- expect(within(summary).getByText('fire')).toBeInTheDocument();
51
+ expect(screen.getByRole('navigation', {name: 'Run workspace'})).toBeInTheDocument();
63
52
  expect(
64
- within(summary).queryByRole('button', {name: COPY_RUN_BUTTON_NAME}),
65
- ).not.toBeInTheDocument();
66
- expect(await screen.findByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
67
- expect(screen.getByRole('button', {name: BUILD_JOB_BUTTON_NAME})).toBeInTheDocument();
68
- expect(screen.getByRole('button', {name: DEPLOY_JOB_BUTTON_NAME})).toBeInTheDocument();
69
- expect(screen.getByRole('button', {name: BUILD_JOB_BUTTON_NAME})).toHaveClass(
70
- 'bg-background-components-hover',
71
- 'hover:bg-background-components-pressed',
53
+ screen
54
+ .getByRole('navigation', {name: 'Run workspace'})
55
+ .closest('[data-run-workspace-layout]'),
56
+ ).toHaveClass('border-t', 'border-border-neutral-base', 'min-[768px]:flex-row');
57
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
58
+ expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
59
+ expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
60
+ expect(screen.getByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
61
+ expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
62
+ expect(container.querySelector('[data-run-workspace-content]')).toHaveClass(
63
+ 'bg-background-neutral-base',
64
+ 'flex-1',
72
65
  );
73
- expect(screen.getByRole('tabpanel')).toHaveClass('max-w-[1120px]', 'px-24');
74
- expect(screen.getByRole('region', {name: 'build'})).toBeInTheDocument();
75
- expect(
76
- screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'}),
77
- ).toBeInTheDocument();
66
+ expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
78
67
  });
79
68
 
80
- test('uses the selected job execution name as the job card title', async () => {
81
- configureApiClient({
82
- fetchImpl: vi.fn(() =>
83
- Promise.resolve(
84
- jsonResponse(
85
- workflowRunViewDetailDto({
86
- jobs: [
87
- workflowJobDto({
88
- id: BUILD_JOB_ID,
89
- run_attempt_id: RUN_ID,
90
- name: JOB_TEMPLATE_NAME,
91
- job_executions: [
92
- workflowJobExecutionDto({
93
- job_id: BUILD_JOB_ID,
94
- name: 'Implement ENG-123',
95
- }),
96
- ],
97
- }),
98
- ],
99
- }),
100
- ),
101
- ),
102
- ),
103
- });
69
+ test('treats the removed Jobs tab URL as the graph Summary', async () => {
70
+ configureRunFetch();
104
71
 
105
- renderView();
72
+ renderView({tab: 'jobs'});
106
73
 
107
- expect(await screen.findByRole('region', {name: 'Implement ENG-123'})).toBeInTheDocument();
108
- expect(screen.queryByRole('region', {name: JOB_TEMPLATE_NAME})).not.toBeInTheDocument();
74
+ expect(await screen.findByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
75
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
109
76
  });
110
77
 
111
- test('renders active step attempt logs inline when the selected job is running', async () => {
112
- const stepId = '99999999-9999-4999-8999-000000000001';
113
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
114
- const url = requestUrl(input);
115
- if (url.pathname === `/steps/${stepId}/attempts/1/logs`) {
116
- return Promise.resolve(jsonResponse(inlineLogBody(outputLine('live output\n'), 1)));
117
- }
118
- return Promise.resolve(
119
- jsonResponse(
120
- workflowRunViewDetailDto({
121
- jobs: [
122
- workflowJobDto({
123
- id: BUILD_JOB_ID,
124
- run_attempt_id: RUN_ID,
125
- name: 'build',
126
- status: 'running',
127
- steps: [
128
- workflowStepDto({
129
- id: stepId,
130
- key: 'test',
131
- name: 'test',
132
- status: 'running',
133
- attempts: [
134
- workflowStepAttemptDto({
135
- id: 'aaaaaaaa-aaaa-4aaa-8aaa-000000000001',
136
- step_id: stepId,
137
- status: 'running',
138
- exit_code: null,
139
- finished_at: null,
140
- }),
141
- ],
142
- }),
143
- ],
144
- }),
145
- ],
146
- }),
147
- ),
148
- );
149
- });
150
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
78
+ test('navigates graph and rail jobs to their dedicated job routes', async () => {
79
+ const user = userEvent.setup();
80
+ configureRunFetch();
151
81
 
152
- renderView();
82
+ const {router} = renderView();
83
+ await user.click(await screen.findByRole('button', {name: 'deploy, Running'}));
153
84
 
154
- expect(await screen.findByText('live output')).toBeInTheDocument();
155
- const logRequest = fetchImpl.mock.calls
156
- .map((call) => new URL((call[0] as Request).url))
157
- .find((url) => url.pathname === `/steps/${stepId}/attempts/1/logs`);
158
- expect(logRequest?.searchParams.get('cursor')).toBe('0');
85
+ await waitFor(() =>
86
+ expect(router.state.location.pathname).toBe(
87
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}/jobs/${DEPLOY_JOB_ID}`,
88
+ ),
89
+ );
159
90
  });
160
91
 
161
- test('switches executions from the listening job header', async () => {
92
+ test('keeps run Annotations and Source in the workspace navigation', async () => {
162
93
  const user = userEvent.setup();
163
- configureApiClient({
164
- fetchImpl: vi.fn(() =>
165
- Promise.resolve(
166
- jsonResponse(
167
- workflowRunViewDetailDto({
168
- jobs: [
169
- workflowJobDto({
170
- id: BUILD_JOB_ID,
171
- run_attempt_id: RUN_ID,
172
- name: 'release-gates',
173
- mode: 'listening',
174
- status: 'running',
175
- listener_status: 'listening',
176
- job_executions: [
177
- workflowJobExecutionDto({
178
- id: '77777777-7777-4777-8777-000000000001',
179
- job_id: BUILD_JOB_ID,
180
- sequence: 1,
181
- name: 'Pull request event',
182
- status: 'succeeded',
183
- trigger_events: [
184
- {
185
- source: 'github',
186
- event: 'pull_request',
187
- delivery_id: 'delivery-1',
188
- received_at: '2026-05-07T01:00:00.000Z',
189
- project: null,
190
- repository: null,
191
- ref: null,
192
- commit: null,
193
- data: {number: 12},
194
- },
195
- ],
196
- started_at: '2026-05-07T01:01:00.000Z',
197
- finished_at: '2026-05-07T01:01:20.000Z',
198
- steps: [
199
- workflowStepDto({
200
- key: 'first-event',
201
- name: 'first-event',
202
- status: 'succeeded',
203
- attempts: [
204
- workflowStepAttemptDto({
205
- status: 'succeeded',
206
- finished_at: '2026-05-07T01:01:20.000Z',
207
- }),
208
- ],
209
- }),
210
- ],
211
- }),
212
- workflowJobExecutionDto({
213
- id: '77777777-7777-4777-8777-000000000002',
214
- job_id: BUILD_JOB_ID,
215
- sequence: 2,
216
- name: 'Deployment status event',
217
- status: 'failed',
218
- status_reason: 'step_failed',
219
- trigger_events: [
220
- {
221
- source: 'github',
222
- event: 'deployment_status',
223
- delivery_id: 'delivery-2',
224
- received_at: '2026-05-07T01:01:00.000Z',
225
- project: null,
226
- repository: null,
227
- ref: null,
228
- commit: null,
229
- data: {state: 'success'},
230
- },
231
- {
232
- source: 'github',
233
- event: 'check_run',
234
- delivery_id: 'delivery-3',
235
- received_at: '2026-05-07T01:01:10.000Z',
236
- project: null,
237
- repository: null,
238
- ref: null,
239
- commit: null,
240
- data: {status: 'completed'},
241
- },
242
- ],
243
- started_at: '2026-05-07T01:02:00.000Z',
244
- finished_at: '2026-05-07T01:02:20.000Z',
245
- steps: [
246
- workflowStepDto({
247
- key: 'second-event',
248
- name: 'second-event',
249
- status: 'failed',
250
- attempts: [
251
- workflowStepAttemptDto({
252
- status: 'failed',
253
- exit_code: 1,
254
- finished_at: '2026-05-07T01:02:20.000Z',
255
- }),
256
- ],
257
- }),
258
- ],
259
- }),
260
- ],
261
- }),
262
- ],
263
- }),
264
- ),
265
- ),
266
- ),
267
- });
94
+ configureRunFetch();
268
95
 
269
- renderView();
270
- expect(
271
- await screen.findByRole('region', {name: 'Deployment status event'}),
272
- ).toBeInTheDocument();
273
- expect(await screen.findByText('second-event')).toBeInTheDocument();
274
- expect(
275
- await screen.findByRole('button', {name: 'github · deployment_status (2 events)'}),
276
- ).toBeInTheDocument();
277
- expect(screen.getByText('deployment_status (2)')).toBeInTheDocument();
278
-
279
- await user.click(screen.getByRole('button', {name: LISTENING_EXECUTION_SWITCHER_NAME}));
280
- await user.click(screen.getByRole('menuitem', {name: EXECUTION_ONE_MENU_ITEM_PATTERN}));
96
+ const {router} = renderView();
97
+ await screen.findByRole('region', {name: 'Workflow jobs'});
98
+ await user.click(screen.getByRole('link', {name: 'Annotations'}));
281
99
 
282
- expect(await screen.findByText('first-event')).toBeInTheDocument();
283
- expect(screen.getByRole('region', {name: 'Pull request event'})).toBeInTheDocument();
284
- expect(screen.queryByText('second-event')).not.toBeInTheDocument();
285
- expect(await screen.findByRole('button', {name: 'github · pull_request'})).toBeInTheDocument();
286
- expect(screen.getByText('pull_request')).toBeInTheDocument();
287
- expect(screen.queryByText('deployment_status (2)')).not.toBeInTheDocument();
100
+ await waitFor(() => expect(router.state.location.search).toMatchObject({tab: 'annotations'}));
288
101
  });
289
102
 
290
- test('shows generic provider setup guidance for agent config failures', async () => {
291
- const user = userEvent.setup();
292
- const stepId = '99999999-9999-4999-8999-000000000004';
293
- const attemptId = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000004';
294
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
295
- const url = requestUrl(input);
296
- if (url.pathname === `/steps/${stepId}/attempts/1/logs`) {
297
- return Promise.resolve(
298
- jsonResponse(inlineLogBody(outputLine('configuration failed\n'), 1)),
299
- );
300
- }
301
- return Promise.resolve(
302
- jsonResponse(
303
- workflowRunViewDetailDto({
304
- jobs: [
305
- workflowJobDto({
306
- id: BUILD_JOB_ID,
307
- run_attempt_id: RUN_ID,
308
- name: 'build',
309
- status: 'failed',
310
- steps: [
311
- workflowStepDto({
312
- id: stepId,
313
- key: 'implement',
314
- name: 'Fix the failing tests.',
315
- type: 'agent',
316
- status: 'failed',
317
- config: {
318
- provider: 'anthropic',
319
- model: 'claude-opus-4-8',
320
- thinking: 'high',
321
- prompt: 'Fix the failing tests.',
322
- },
323
- error: {
324
- message: 'Model provider request failed',
325
- reason: 'agent_invocation_failed',
326
- category: 'user',
327
- },
328
- attempts: [
329
- workflowStepAttemptDto({
330
- id: attemptId,
331
- step_id: stepId,
332
- status: 'failed',
333
- exit_code: 1,
334
- error: {
335
- message: 'Model provider credentials are not configured',
336
- reason: 'agent_config_invalid',
337
- agentConfigIssue: 'provider_not_configured',
338
- },
339
- finished_at: '2026-05-07T01:01:20.000Z',
340
- }),
341
- ],
342
- }),
343
- ],
344
- }),
345
- ],
346
- }),
347
- ),
348
- );
349
- });
350
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
103
+ test('filters the single run-level Annotations page by job', async () => {
104
+ configureRunFetch();
351
105
 
352
- renderView();
353
- await user.click(
354
- await screen.findByRole('button', {
355
- name: 'Fix the failing tests., Failed, attempt 1, User',
356
- }),
357
- );
106
+ renderView({tab: 'annotations', selection: {jobId: BUILD_JOB_ID}});
358
107
 
359
108
  expect(
360
- screen.queryByRole('region', {name: 'Resolved agent configuration'}),
361
- ).not.toBeInTheDocument();
362
- expect(screen.queryByText('anthropic')).not.toBeInTheDocument();
363
- expect(screen.queryByText('claude-opus-4-8')).not.toBeInTheDocument();
364
- expect(screen.queryByText('high')).not.toBeInTheDocument();
365
- expect(screen.getByText('Configure credentials for anthropic')).toBeInTheDocument();
366
- expect(
367
- screen.getByText(
368
- 'This step uses anthropic, but no workspace credentials are configured for that model provider. Configure anthropic in Agents, then re-run the workflow.',
369
- ),
370
- ).toBeInTheDocument();
371
- expect(screen.getByRole('link', {name: 'Configure Agents'})).toHaveAttribute(
372
- 'href',
373
- `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
374
- );
109
+ await screen.findByRole('combobox', {name: 'Filter annotations by job'}),
110
+ ).toHaveTextContent('build');
111
+ expect(await screen.findByText('This run has no annotations to show.')).toBeInTheDocument();
112
+ expect(screen.queryByRole('tab', {name: 'Annotations'})).not.toBeInTheDocument();
375
113
  });
376
114
 
377
- test('falls back to the step error when an agent attempt has no typed error', async () => {
378
- const user = userEvent.setup();
379
- const stepId = '99999999-9999-4999-8999-000000000005';
380
- const attemptId = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000005';
381
- configureApiClient({
382
- fetchImpl: vi.fn((input: RequestInfo | URL) => {
383
- const url = requestUrl(input);
384
- if (url.pathname === `/steps/${stepId}/attempts/1/logs`) {
385
- return Promise.resolve(
386
- jsonResponse(inlineLogBody(outputLine('configuration failed\n'), 1)),
387
- );
388
- }
389
- return Promise.resolve(
390
- jsonResponse(
391
- workflowRunViewDetailDto({
392
- jobs: [
393
- workflowJobDto({
394
- id: BUILD_JOB_ID,
395
- run_attempt_id: RUN_ID,
396
- name: 'build',
397
- status: 'failed',
398
- steps: [
399
- workflowStepDto({
400
- id: stepId,
401
- key: 'implement',
402
- name: 'Fix the failing tests.',
403
- type: 'agent',
404
- status: 'failed',
405
- config: {
406
- provider: 'anthropic',
407
- model: 'claude-opus-4-8',
408
- thinking: 'high',
409
- prompt: 'Fix the failing tests.',
410
- },
411
- error: {
412
- message: 'Model provider credentials are not configured',
413
- reason: 'agent_config_invalid',
414
- agent_config_issue: 'provider_not_configured',
415
- category: 'user',
416
- },
417
- attempts: [
418
- workflowStepAttemptDto({
419
- id: attemptId,
420
- step_id: stepId,
421
- status: 'failed',
422
- exit_code: 1,
423
- error: null,
424
- finished_at: '2026-05-07T01:01:20.000Z',
425
- }),
426
- ],
427
- }),
428
- ],
429
- }),
430
- ],
431
- }),
432
- ),
433
- );
115
+ test('renders annotation bodies once, severity first, with provenance', async () => {
116
+ configureRunFetch([
117
+ annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage', style: 'info', sequence: 1}),
118
+ annotationDto({
119
+ id: ANNOTATION_ID_TWO,
120
+ context: 'smoke check',
121
+ style: 'error',
122
+ sequence: 2,
123
+ body: 'Task nine **failed**.',
434
124
  }),
435
- });
125
+ ]);
436
126
 
437
- renderView();
438
- await user.click(
439
- await screen.findByRole('button', {
440
- name: 'Fix the failing tests., Failed, attempt 1, User',
441
- }),
442
- );
127
+ renderView({tab: 'annotations'});
443
128
 
444
- expect(screen.queryByText('anthropic')).not.toBeInTheDocument();
445
- expect(screen.queryByText('claude-opus-4-8')).not.toBeInTheDocument();
446
- expect(screen.queryByText('high')).not.toBeInTheDocument();
447
- expect(screen.getByText('Configure credentials for anthropic')).toBeInTheDocument();
448
- expect(screen.getByRole('link', {name: 'Configure Agents'})).toHaveAttribute(
449
- 'href',
450
- `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
451
- );
129
+ const headings = await screen.findAllByRole('heading', {level: 3});
130
+ expect(headings.map((heading) => heading.textContent)).toEqual(['smoke check', 'coverage']);
131
+ expect(screen.getByText(TASK_NINE_PATTERN)).toBeInTheDocument();
132
+ expect(screen.getAllByText('build · checkout · attempt 1')).toHaveLength(2);
133
+ expect(screen.getByText('2 annotations')).toBeInTheDocument();
452
134
  });
453
135
 
454
- test('renders skipped zero-attempt jobs as skipped instead of missing attempts', async () => {
455
- configureApiClient({
456
- fetchImpl: vi.fn(() =>
457
- Promise.resolve(
458
- jsonResponse(
459
- workflowRunViewDetailDto({
460
- jobs: [
461
- workflowJobDto({
462
- id: DEPLOY_JOB_ID,
463
- run_attempt_id: RUN_ID,
464
- name: 'deploy',
465
- status: 'skipped',
466
- status_reason: 'dependency_not_completed',
467
- job_executions: [],
468
- }),
469
- ],
470
- }),
471
- ),
472
- ),
473
- ),
474
- });
136
+ test('links an annotation back to the step that emitted it', async () => {
137
+ configureRunFetch([annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage'})]);
475
138
 
476
- renderView();
139
+ renderView({tab: 'annotations'});
477
140
 
478
- expect(await screen.findByText('This job was skipped')).toBeInTheDocument();
479
- expect(
480
- screen.getByText('A required job did not complete, so this job was skipped.'),
481
- ).toBeInTheDocument();
482
- expect(screen.queryByText('No steps recorded')).not.toBeInTheDocument();
483
- });
141
+ const href = (await screen.findByRole('link', {name: 'Open step'})).getAttribute('href') ?? '';
142
+ const [pathname, query = ''] = href.split('?');
484
143
 
485
- test('keeps a terminal job status over inconsistent zero-step execution activity', async () => {
486
- configureApiClient({
487
- fetchImpl: vi.fn(() =>
488
- Promise.resolve(
489
- jsonResponse(
490
- workflowRunViewDetailDto({
491
- jobs: [
492
- workflowJobDto({
493
- id: DEPLOY_JOB_ID,
494
- run_attempt_id: RUN_ID,
495
- name: 'deploy',
496
- status: 'succeeded',
497
- job_executions: [
498
- workflowJobExecutionDto({
499
- id: '77777777-7777-4777-8777-000000000003',
500
- job_id: DEPLOY_JOB_ID,
501
- sequence: 2,
502
- name: 'retry',
503
- status: 'running',
504
- steps: [],
505
- }),
506
- ],
507
- }),
508
- ],
509
- }),
510
- ),
511
- ),
512
- ),
144
+ expect(pathname).toBe(`/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}/jobs/${BUILD_JOB_ID}`);
145
+ expect(Object.fromEntries(new URLSearchParams(query))).toEqual({
146
+ jobExecution: BUILD_EXECUTION_ID,
147
+ step: BUILD_STEP_ID,
148
+ stepAttempt: BUILD_ATTEMPT_ID,
149
+ runAttempt: '"1"',
513
150
  });
514
-
515
- renderView();
516
-
517
- expect(await screen.findByText('No steps recorded')).toBeInTheDocument();
518
- expect(screen.getByText('Execution #2 finished without recorded steps.')).toBeInTheDocument();
519
- expect(screen.queryByText('Waiting for the first step')).not.toBeInTheDocument();
520
151
  });
521
152
 
522
- test('renders pending zero-step jobs as waiting to start', async () => {
153
+ test('shows a failed annotations read as an error, never as an empty run', async () => {
523
154
  configureApiClient({
524
- fetchImpl: vi.fn(() =>
155
+ fetchImpl: vi.fn((input: RequestInfo | URL) =>
525
156
  Promise.resolve(
526
- jsonResponse(
527
- workflowRunViewDetailDto({
528
- jobs: [
529
- workflowJobDto({
530
- id: DEPLOY_JOB_ID,
531
- run_attempt_id: RUN_ID,
532
- name: 'deploy',
533
- status: 'pending',
534
- steps: [],
535
- }),
536
- ],
537
- }),
538
- ),
157
+ requestUrl(input).includes('/annotations')
158
+ ? jsonResponse({code: 'internal'}, {status: 500})
159
+ : jsonResponse(workflowRunViewDetailDto()),
539
160
  ),
540
161
  ),
541
162
  });
542
163
 
543
- renderView();
164
+ renderView({tab: 'annotations'});
544
165
 
545
- expect(await screen.findByText('Waiting for this job to start')).toBeInTheDocument();
546
- expect(screen.getByText('Steps will appear here once the job starts.')).toBeInTheDocument();
547
- expect(screen.queryByText('No steps recorded')).not.toBeInTheDocument();
166
+ expect(await screen.findByRole('button', {name: 'Retry loading annotations'})).toBeVisible();
167
+ expect(screen.queryByText('This run has no annotations to show.')).not.toBeInTheDocument();
548
168
  });
549
169
 
550
- test('renders claimed idle jobs as runner preparation', async () => {
551
- configureApiClient({
552
- fetchImpl: vi.fn(() =>
553
- Promise.resolve(
554
- jsonResponse(
555
- workflowRunViewDetailDto({
556
- jobs: [
557
- workflowJobDto({
558
- id: DEPLOY_JOB_ID,
559
- run_attempt_id: RUN_ID,
560
- name: 'deploy',
561
- status: 'running',
562
- job_executions: [
563
- workflowJobExecutionDto({
564
- job_id: DEPLOY_JOB_ID,
565
- status: 'running',
566
- steps: [],
567
- }),
568
- ],
569
- }),
570
- ],
571
- }),
572
- ),
573
- ),
170
+ test('bounds how many annotation bodies render at once', async () => {
171
+ configureRunFetch(
172
+ Array.from({length: 30}, (_unused, index) =>
173
+ annotationDto({
174
+ id: `aaaaaaaa-aaaa-4aaa-8aaa-${String(index).padStart(12, '0')}`,
175
+ context: `context-${index}`,
176
+ sequence: index + 1,
177
+ }),
574
178
  ),
575
- });
179
+ );
576
180
 
577
- renderView();
181
+ renderView({tab: 'annotations'});
578
182
 
579
- expect(await screen.findByText('Runner preparing job')).toBeInTheDocument();
580
- expect(
581
- screen.getByText('A runner is assigned. Steps will appear here when work begins.'),
582
- ).toBeInTheDocument();
583
- expect(
584
- screen.queryByText('This job is running, but no steps have started yet.'),
585
- ).not.toBeInTheDocument();
586
- expect(screen.queryByText('No steps recorded')).not.toBeInTheDocument();
183
+ expect(await screen.findAllByRole('heading', {level: 3})).toHaveLength(25);
184
+ const showMore = screen.getByRole('button', {name: 'Show 5 more of 30'});
185
+
186
+ await userEvent.click(showMore);
187
+
188
+ expect(screen.getAllByRole('heading', {level: 3})).toHaveLength(30);
189
+ expect(screen.queryByRole('button', {name: SHOW_MORE_PATTERN})).not.toBeInTheDocument();
587
190
  });
588
191
 
589
- test('renders active listening jobs with no executions as waiting for trigger events', async () => {
590
- configureApiClient({
591
- fetchImpl: vi.fn(() =>
592
- Promise.resolve(
593
- jsonResponse(
594
- workflowRunViewDetailDto({
595
- jobs: [
596
- workflowJobDto({
597
- id: DEPLOY_JOB_ID,
598
- run_attempt_id: RUN_ID,
599
- name: 'release-gates',
600
- mode: 'listening',
601
- status: 'running',
602
- listener_status: 'listening',
603
- job_executions: [],
604
- }),
605
- ],
606
- }),
607
- ),
608
- ),
192
+ test('focuses a deep-linked annotation beyond the render window', async () => {
193
+ const deepLinkedId = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000029';
194
+ configureRunFetch(
195
+ Array.from({length: 30}, (_unused, index) =>
196
+ annotationDto({
197
+ id:
198
+ index === 29
199
+ ? deepLinkedId
200
+ : `aaaaaaaa-aaaa-4aaa-8aaa-${String(index).padStart(12, '0')}`,
201
+ context: `context-${index}`,
202
+ sequence: index + 1,
203
+ }),
609
204
  ),
610
- });
205
+ );
611
206
 
612
- renderView();
207
+ renderView({tab: 'annotations', selection: {annotation: deepLinkedId}});
613
208
 
614
- expect(await screen.findByText('Waiting for trigger events')).toBeInTheDocument();
615
- expect(
616
- screen.getByText('Matching trigger events will create job executions here.'),
617
- ).toBeInTheDocument();
618
- expect(screen.queryByText('Execution details unavailable')).not.toBeInTheDocument();
209
+ const target = await screen.findByRole('heading', {level: 3, name: 'context-29'});
210
+ const selected = target.closest('li');
211
+ await waitFor(() => expect(selected).toHaveFocus());
212
+ expect(selected).toHaveAttribute('aria-current', 'true');
213
+ expect(selected).toHaveClass('shadow-border-interactive-with-active');
619
214
  });
620
215
 
621
- test('renders finished one-shot jobs with no execution as unavailable execution details', async () => {
622
- configureApiClient({
623
- fetchImpl: vi.fn(() =>
624
- Promise.resolve(
625
- jsonResponse(
626
- workflowRunViewDetailDto({
627
- jobs: [
628
- workflowJobDto({
629
- id: DEPLOY_JOB_ID,
630
- run_attempt_id: RUN_ID,
631
- name: 'deploy',
632
- status: 'succeeded',
633
- job_executions: [],
634
- }),
635
- ],
636
- }),
637
- ),
638
- ),
639
- ),
640
- });
216
+ test('separates a filtered miss from a run with no annotations', async () => {
217
+ configureRunFetch([annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage', style: 'info'})]);
641
218
 
642
- renderView();
219
+ renderView({tab: 'annotations', selection: {severity: 'error'}});
643
220
 
644
- expect(await screen.findByText('Execution details unavailable')).toBeInTheDocument();
221
+ expect(await screen.findByText('No matching annotations')).toBeInTheDocument();
645
222
  expect(
646
- screen.getByText('This job finished, but no job execution record is available.'),
223
+ screen.getByText('This run has annotations, but none at error severity.'),
647
224
  ).toBeInTheDocument();
648
- expect(screen.queryByText('Waiting for trigger events')).not.toBeInTheDocument();
225
+ expect(screen.getByRole('button', {name: 'Clear filters'})).toBeInTheDocument();
649
226
  });
650
227
 
651
- test('renders cancelled zero-attempt jobs separately from skipped jobs', async () => {
652
- configureApiClient({
653
- fetchImpl: vi.fn(() =>
654
- Promise.resolve(
655
- jsonResponse(
656
- workflowRunViewDetailDto({
657
- jobs: [
658
- workflowJobDto({
659
- id: DEPLOY_JOB_ID,
660
- run_attempt_id: RUN_ID,
661
- name: 'deploy',
662
- status: 'cancelled',
663
- job_executions: [
664
- workflowJobExecutionDto({
665
- job_id: DEPLOY_JOB_ID,
666
- status: 'cancelled',
667
- steps: [],
668
- }),
669
- ],
670
- }),
671
- ],
672
- }),
673
- ),
674
- ),
675
- ),
676
- });
228
+ test('does not claim a filtered miss beyond the loaded annotation budget', async () => {
229
+ configureRunFetch(
230
+ [annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage', style: 'info'})],
231
+ {},
232
+ {has_more: true, next_cursor: 'next-page'},
233
+ );
677
234
 
678
- renderView();
235
+ renderView({tab: 'annotations', selection: {severity: 'error'}});
679
236
 
680
- expect(await screen.findByText('Cancelled before start')).toBeInTheDocument();
681
- expect(screen.getByText('This job was cancelled before any step started.')).toBeInTheDocument();
682
- expect(screen.queryByText('This job was skipped')).not.toBeInTheDocument();
237
+ expect(await screen.findByText('No matches in loaded annotations')).toBeInTheDocument();
238
+ expect(
239
+ screen.getByText(
240
+ 'None of the loaded annotations are at error severity. Load more annotations to continue searching.',
241
+ ),
242
+ ).toBeInTheDocument();
243
+ expect(screen.getByRole('button', {name: 'Load more annotations'})).toBeInTheDocument();
244
+ expect(screen.queryByText('No matching annotations')).not.toBeInTheDocument();
683
245
  });
684
246
 
685
- test('shows the not-found surface when the run 404s', async () => {
686
- configureApiClient({
687
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}))),
247
+ test('clears annotation filters and legacy step selection together', async () => {
248
+ const user = userEvent.setup();
249
+ configureRunFetch([annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage', style: 'info'})]);
250
+
251
+ const {router} = renderView({
252
+ tab: 'annotations',
253
+ selection: {
254
+ jobId: DEPLOY_JOB_ID,
255
+ jobExecutionId: BUILD_EXECUTION_ID,
256
+ stepId: BUILD_STEP_ID,
257
+ stepAttemptId: BUILD_ATTEMPT_ID,
258
+ severity: 'error',
259
+ annotation: ANNOTATION_ID_ONE,
260
+ },
688
261
  });
689
262
 
690
- renderView();
263
+ await user.click(await screen.findByRole('button', {name: 'Clear filters'}));
691
264
 
692
- expect(await screen.findByText('Run not found')).toBeInTheDocument();
265
+ await waitFor(() => expect(router.state.location.search).toEqual({tab: 'annotations'}));
693
266
  });
694
267
 
695
- test('shows the load-error placeholder when the run fails to load', async () => {
696
- configureApiClient({
697
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}))),
698
- });
268
+ test('blames only the filters that are actually active', async () => {
269
+ // The annotation belongs to build, so filtering to deploy is a job-only miss. Naming
270
+ // severity here would send the reader to change a control they never touched.
271
+ configureRunFetch([annotationDto({id: ANNOTATION_ID_ONE, context: 'coverage', style: 'info'})]);
699
272
 
700
- renderView();
273
+ renderView({tab: 'annotations', selection: {jobId: DEPLOY_JOB_ID}});
701
274
 
702
275
  expect(
703
- await screen.findByRole('button', {name: 'Retry loading workflow run'}),
276
+ await screen.findByText('This run has annotations, but none from deploy.'),
704
277
  ).toBeInTheDocument();
705
278
  });
706
279
 
707
- test('renders the full workflow source in the Source tab', async () => {
280
+ test('renders the captured workflow source in the Source section', async () => {
708
281
  configureApiClient({
709
282
  fetchImpl: vi.fn(() =>
710
283
  Promise.resolve(
711
284
  jsonResponse(
712
285
  workflowRunViewDetailDto({
713
- source_snapshot: {
714
- format: 'yaml',
715
- content: 'jobs:\n build:\n steps:\n - run: pnpm test',
716
- },
286
+ source_snapshot: {format: 'yaml', content: 'jobs:\n build:\n steps: []'},
717
287
  }),
718
288
  ),
719
289
  ),
@@ -722,496 +292,81 @@ describe('WorkflowRunView', () => {
722
292
 
723
293
  renderView({tab: 'source'});
724
294
 
725
- expect(await screen.findByRole('tab', {name: 'Source'})).toHaveAttribute(
726
- 'aria-selected',
727
- 'true',
728
- );
729
- expect(await screen.findByText('pnpm test')).toBeInTheDocument();
730
- expect(screen.queryByRole('dialog', {name: 'Workflow source'})).not.toBeInTheDocument();
731
- });
732
-
733
- test('highlights selected step source lines when the source panel opens', async () => {
734
- const user = userEvent.setup();
735
- const stepId = '99999999-9999-4999-8999-000000000003';
736
- const stepAttemptId = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000003';
737
- configureApiClient({
738
- fetchImpl: vi.fn(() =>
739
- Promise.resolve(
740
- jsonResponse(
741
- workflowRunViewDetailDto({
742
- source_snapshot: {
743
- format: 'yaml',
744
- content: 'jobs:\n deploy:\n steps:\n - run: ship',
745
- },
746
- jobs: [
747
- workflowJobDto({
748
- id: DEPLOY_JOB_ID,
749
- run_attempt_id: RUN_ID,
750
- name: 'deploy',
751
- status: 'running',
752
- job_executions: [
753
- workflowJobExecutionDto({
754
- job_id: DEPLOY_JOB_ID,
755
- name: 'deploy',
756
- status: 'running',
757
- steps: [
758
- workflowStepDto({
759
- id: stepId,
760
- key: 'deploy',
761
- name: 'deploy',
762
- source_location: {start_line: 2, end_line: 3},
763
- status: 'running',
764
- attempts: [
765
- workflowStepAttemptDto({
766
- id: stepAttemptId,
767
- step_id: stepId,
768
- status: 'running',
769
- }),
770
- ],
771
- }),
772
- ],
773
- }),
774
- ],
775
- }),
776
- ],
777
- }),
778
- ),
779
- ),
780
- ),
781
- });
782
-
783
- renderView({tab: 'jobs', selection: {stepId, stepAttemptId}});
784
- const jobRegion = await screen.findByRole('region', {name: 'deploy'});
785
- await user.click(within(jobRegion).getByRole('button', {name: 'View source'}));
786
-
787
- const sourceDialog = await screen.findByRole('dialog', {name: 'Workflow source'});
788
- const highlightedLines = sourceDialog.querySelectorAll('.line.highlighted-line');
789
- expect(highlightedLines).toHaveLength(2);
790
- expect(highlightedLines[0]).toHaveTextContent('deploy:');
791
- expect(highlightedLines[1]).toHaveTextContent('steps:');
295
+ expect(await screen.findByText('build:')).toBeInTheDocument();
296
+ expect(screen.getByRole('link', {name: 'Source'})).toHaveAttribute('aria-current', 'page');
792
297
  });
793
298
 
794
299
  test('explains when the run has no source snapshot', async () => {
795
- configureApiClient({
796
- fetchImpl: vi.fn(() =>
797
- Promise.resolve(jsonResponse(workflowRunViewDetailDto({source_snapshot: null}))),
798
- ),
799
- });
300
+ configureRunFetch();
800
301
 
801
302
  renderView({tab: 'source'});
802
303
 
803
- await screen.findByRole('region', {name: 'deploy-web'});
804
-
805
304
  expect(await screen.findByText('Source snapshot unavailable')).toBeInTheDocument();
806
305
  });
807
306
 
808
- test('opens the source panel highlighting a step from the job header Source button', async () => {
809
- const user = userEvent.setup();
810
- configureApiClient({
811
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(locatedStepSourceDetail()))),
812
- });
813
-
814
- renderView();
815
-
816
- const jobRegion = await screen.findByRole('region', {name: 'build'});
817
- await user.click(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'}));
818
- const jobSourceButton = within(jobRegion).getByRole('button', {name: 'View source'});
819
- expect(jobSourceButton).toHaveAttribute('aria-expanded', 'false');
820
- expect(jobSourceButton).toHaveAttribute('aria-controls');
821
-
822
- await user.click(jobSourceButton);
823
-
824
- const sourceDialog = await screen.findByRole('dialog', {name: 'Workflow source'});
825
- const highlighted = sourceDialog.querySelectorAll('.line.highlighted-line');
826
- expect(highlighted).toHaveLength(2);
827
- expect(highlighted[0]).toHaveTextContent('build:');
828
- expect(highlighted[1]).toHaveTextContent('steps:');
829
- expect(jobSourceButton).toHaveAttribute('aria-expanded', 'true');
830
- });
831
-
832
- test('returns focus to the job Source button on close and preserves the selected job', async () => {
833
- const user = userEvent.setup();
834
- configureApiClient({
835
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(locatedStepSourceDetail()))),
836
- });
837
-
838
- renderView();
839
-
840
- const jobRegion = await screen.findByRole('region', {name: 'build'});
841
- await user.click(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'}));
842
- const jobSourceButton = within(jobRegion).getByRole('button', {name: 'View source'});
843
-
844
- await user.click(jobSourceButton);
845
- await screen.findByRole('dialog', {name: 'Workflow source'});
846
- await user.click(screen.getByRole('button', {name: 'Close source'}));
847
-
848
- await waitFor(() => expect(jobSourceButton).toHaveFocus());
849
- expect(jobSourceButton).toHaveAttribute('aria-expanded', 'false');
850
- expect(screen.getByRole('region', {name: 'build'})).toBeInTheDocument();
851
- });
852
-
853
- test('closes the source panel when refetch removes the focused step source', async () => {
854
- const user = userEvent.setup();
855
- let detailRequests = 0;
856
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
857
- const url = requestUrl(input);
858
- if (url.pathname === `/workflows/runs/${RUN_ID}`) {
859
- detailRequests += 1;
860
- return Promise.resolve(
861
- jsonResponse(
862
- detailRequests === 1
863
- ? locatedStepSourceDetail()
864
- : locatedStepSourceDetail({
865
- jobs: [
866
- workflowJobDto({
867
- id: BUILD_JOB_ID,
868
- name: 'build',
869
- status: 'succeeded',
870
- steps: [
871
- workflowStepDto({
872
- id: CHECKOUT_STEP_ID,
873
- name: 'checkout',
874
- status: 'succeeded',
875
- source_location: null,
876
- attempts: [
877
- workflowStepAttemptDto({
878
- id: CHECKOUT_ATTEMPT_ID,
879
- step_id: CHECKOUT_STEP_ID,
880
- status: 'succeeded',
881
- exit_code: 0,
882
- finished_at: '2026-05-07T01:01:20.000Z',
883
- }),
884
- ],
885
- }),
886
- ],
887
- }),
888
- ],
889
- }),
890
- ),
891
- );
892
- }
893
- return Promise.resolve(jsonResponse(inlineLogBody(outputLine('done\n'), 1)));
894
- });
895
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
896
-
897
- const {queryClient} = renderView();
898
-
899
- const jobRegion = await screen.findByRole('region', {name: 'build'});
900
- await user.click(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'}));
901
- const jobSourceButton = within(jobRegion).getByRole('button', {name: 'View source'});
902
- await user.click(jobSourceButton);
903
- await screen.findByRole('dialog', {name: 'Workflow source'});
904
-
905
- await queryClient.refetchQueries({queryKey: workflowRunsQueryKeys.detail(RUN_ID)});
906
- await waitFor(() =>
907
- expect(
908
- within(jobRegion).queryByRole('button', {name: 'View source'}),
909
- ).not.toBeInTheDocument(),
910
- );
911
- expect(screen.queryByRole('dialog', {name: 'Workflow source'})).not.toBeInTheDocument();
912
- });
913
-
914
- test('re-runs all jobs from a succeeded run and navigates to the new run', async () => {
915
- const user = userEvent.setup();
916
- const rerunId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
917
- const successSpy = vi.spyOn(toast, 'success').mockImplementation(() => 'toast-id');
918
- const postBodies: unknown[] = [];
919
- const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
920
- const request = input as Request;
921
- const url = requestUrl(input);
922
- if (request.method === 'POST' && url.pathname === `/workflows/runs/${RUN_ID}/rerun`) {
923
- postBodies.push(await request.clone().json());
924
- return jsonResponse(workflowRunDto({id: rerunId, status: 'pending'}));
925
- }
926
- return jsonResponse(
927
- workflowRunViewDetailDto({
928
- status: 'succeeded',
929
- jobs: [
930
- workflowJobDto({
931
- id: BUILD_JOB_ID,
932
- run_attempt_id: RUN_ID,
933
- name: 'build',
934
- status: 'succeeded',
935
- }),
936
- ],
937
- }),
938
- );
939
- });
940
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
941
-
942
- const {router} = renderView({}, '?runAttempt=1');
943
- await user.click(await screen.findByRole('button', {name: 'Re-run workflow'}));
944
-
945
- const postRequest = await findRequest(fetchImpl, 'POST', `/workflows/runs/${RUN_ID}/rerun`);
946
- expect(postRequest).toBeDefined();
947
- expect(postBodies).toEqual([{mode: 'all'}]);
948
- expect(successSpy).toHaveBeenCalledWith('Re-run started');
949
- await waitFor(() =>
950
- expect(router.state.location.pathname).toBe(
951
- `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${rerunId}`,
952
- ),
953
- );
954
- expect((router.state.location.search as Record<string, unknown>).runAttempt).toBeUndefined();
955
- });
956
-
957
- test('re-runs failed jobs from the dropdown', async () => {
958
- const user = userEvent.setup();
959
- const postBodies: unknown[] = [];
960
- const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
961
- const request = input as Request;
962
- const url = requestUrl(input);
963
- if (request.method === 'POST' && url.pathname === `/workflows/runs/${RUN_ID}/rerun`) {
964
- postBodies.push(await request.clone().json());
965
- return jsonResponse(workflowRunDto({id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'}));
966
- }
967
- return jsonResponse(
968
- workflowRunViewDetailDto({
969
- status: 'failed',
970
- jobs: [
971
- workflowJobDto({
972
- id: BUILD_JOB_ID,
973
- run_attempt_id: RUN_ID,
974
- name: 'build',
975
- status: 'failed',
976
- }),
977
- workflowJobDto({
978
- id: DEPLOY_JOB_ID,
979
- run_attempt_id: RUN_ID,
980
- name: 'deploy',
981
- status: 'cancelled',
982
- position: 1,
983
- }),
984
- ],
985
- }),
986
- );
987
- });
988
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
989
-
990
- renderView();
991
- await user.click(await screen.findByRole('button', {name: 'Re-run jobs'}));
992
- await user.click(await screen.findByRole('menuitem', {name: 'Re-run failed jobs'}));
993
-
994
- const postRequest = await findRequest(fetchImpl, 'POST', `/workflows/runs/${RUN_ID}/rerun`);
995
- expect(postRequest).toBeDefined();
996
- expect(postBodies).toEqual([{mode: 'failed'}]);
997
- });
998
-
999
- test('shows an error toast when rerun creation fails', async () => {
1000
- const user = userEvent.setup();
1001
- const errorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id');
1002
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
1003
- const request = input as Request;
1004
- const url = requestUrl(input);
1005
- if (request.method === 'POST' && url.pathname === `/workflows/runs/${RUN_ID}/rerun`) {
1006
- return Promise.resolve(
1007
- jsonResponse({code: 'no-failed-jobs', message: 'Run has no failed jobs'}, {status: 409}),
1008
- );
1009
- }
1010
- return Promise.resolve(jsonResponse(workflowRunViewDetailDto({status: 'succeeded'})));
1011
- });
1012
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
1013
-
1014
- renderView();
1015
- await user.click(await screen.findByRole('button', {name: 'Re-run workflow'}));
1016
-
1017
- await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Run has no failed jobs'));
1018
- });
1019
-
1020
- test('selects another run attempt and clears job selection search', async () => {
1021
- const user = userEvent.setup();
1022
- const secondAttemptId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
1023
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
1024
- const url = requestUrl(input);
1025
- if (url.pathname === `/workflows/runs/${RUN_ID}/attempts`) {
1026
- return Promise.resolve(
1027
- jsonResponse(
1028
- runAttemptsResponseDto({
1029
- attempts: [
1030
- workflowRunAttemptDto({
1031
- id: RUN_ID,
1032
- attempt: 1,
1033
- status: 'succeeded',
1034
- created_at: '2026-05-07T01:01:00.000Z',
1035
- }),
1036
- workflowRunAttemptDto({
1037
- id: secondAttemptId,
1038
- attempt: 2,
1039
- status: 'running',
1040
- created_at: '2026-05-07T01:02:00.000Z',
1041
- rerun_mode: 'all',
1042
- }),
1043
- ],
1044
- }),
1045
- ),
1046
- );
1047
- }
1048
- return Promise.resolve(
1049
- jsonResponse(
1050
- workflowRunViewDetailDto({
1051
- current_attempt: 1,
1052
- latest_attempt: 2,
1053
- run_attempt: workflowRunAttemptDto({id: RUN_ID, workflow_run_id: RUN_ID, attempt: 1}),
1054
- }),
1055
- ),
1056
- );
1057
- });
1058
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
1059
-
1060
- const {router} = renderProjectPage(
1061
- `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}?job=${BUILD_JOB_ID}&step=${CHECKOUT_STEP_ID}&stepAttempt=${CHECKOUT_ATTEMPT_ID}`,
1062
- () => (
1063
- <WorkflowRunView
1064
- projectId={PROJECT_ID}
1065
- workspaceSlug={PROJECT_TEST_WSLUG}
1066
- projectSlug="project"
1067
- workflowRunId={RUN_ID}
1068
- />
1069
- ),
1070
- );
1071
-
1072
- await user.click(await screen.findByRole('button', {name: 'Switch attempt, currently 1 of 2'}));
1073
- await user.click(await screen.findByRole('menuitem', {name: ATTEMPT_2_PATTERN}));
1074
-
1075
- await waitFor(() =>
1076
- expect(router.state.location.pathname).toBe(
1077
- `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}`,
1078
- ),
1079
- );
1080
- expect(router.state.location.search).toEqual({runAttempt: 2});
1081
- });
1082
-
1083
- test('does not render rerun controls for non-terminal runs', async () => {
307
+ test('shows the not-found surface when the run 404s', async () => {
1084
308
  configureApiClient({
1085
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
309
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}))),
1086
310
  });
1087
311
 
1088
312
  renderView();
1089
313
 
1090
- await screen.findByRole('region', {name: 'deploy-web'});
1091
- expect(screen.queryByRole('button', {name: RERUN_BUTTON_NAME})).not.toBeInTheDocument();
1092
- });
1093
-
1094
- test('renders carried-over steps without requesting attempt logs', async () => {
1095
- const user = userEvent.setup();
1096
- const fetchImpl = vi.fn(() =>
1097
- Promise.resolve(
1098
- jsonResponse(
1099
- workflowRunViewDetailDto({
1100
- status: 'succeeded',
1101
- jobs: [
1102
- workflowJobDto({
1103
- id: BUILD_JOB_ID,
1104
- run_attempt_id: RUN_ID,
1105
- name: 'build',
1106
- status: 'succeeded',
1107
- carried_over: true,
1108
- steps: [
1109
- workflowStepDto({
1110
- id: CHECKOUT_STEP_ID,
1111
- key: 'checkout',
1112
- name: 'checkout',
1113
- status: 'succeeded',
1114
- attempts: [],
1115
- }),
1116
- ],
1117
- }),
1118
- ],
1119
- }),
1120
- ),
1121
- ),
1122
- );
1123
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
1124
-
1125
- renderView();
1126
- expect(
1127
- await screen.findByRole('button', {name: 'build, Succeeded, reused'}),
1128
- ).toBeInTheDocument();
1129
- expect(screen.getAllByText('reused')).toHaveLength(1);
1130
- await user.click(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'}));
1131
-
1132
- expect(await screen.findByText('Not executed in this run.')).toBeInTheDocument();
1133
- expect(
1134
- mockRequests(fetchImpl).some((request) => requestUrl(request).pathname.includes('/logs')),
1135
- ).toBe(false);
314
+ expect(await screen.findByText('Run not found')).toBeInTheDocument();
1136
315
  });
1137
316
  });
1138
317
 
1139
- function renderView(props: Partial<Parameters<typeof WorkflowRunView>[0]> = {}, search = '') {
1140
- return renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}${search}`, () => (
318
+ function renderView(props: Partial<Parameters<typeof WorkflowRunView>[0]> = {}) {
319
+ return renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}`, () => (
1141
320
  <WorkflowRunView
1142
321
  projectId={PROJECT_ID}
1143
322
  workspaceSlug={PROJECT_TEST_WSLUG}
1144
323
  projectSlug="project"
1145
324
  workflowRunId={RUN_ID}
1146
- tab="jobs"
1147
325
  {...props}
1148
326
  />
1149
327
  ));
1150
328
  }
1151
329
 
1152
- function requestUrl(input: RequestInfo | URL): URL {
1153
- if (input instanceof Request) return new URL(input.url);
1154
- return new URL(String(input));
330
+ /** The API client hands `fetchImpl` a `Request`, whose URL only `.url` exposes. */
331
+ function requestUrl(input: RequestInfo | URL): string {
332
+ if (typeof input === 'string') return input;
333
+ return input instanceof URL ? input.href : input.url;
1155
334
  }
1156
335
 
1157
- async function findRequest(
1158
- fetchImpl: ReturnType<typeof vi.fn>,
1159
- method: string,
1160
- pathname: string,
1161
- ): Promise<Request> {
1162
- await waitFor(() => {
1163
- const match = mockRequests(fetchImpl).find(
1164
- (request) => request.method === method && requestUrl(request).pathname === pathname,
1165
- );
1166
- expect(match).toBeDefined();
336
+ /** Routes the run detail and the annotations read, which are two independent fetches. */
337
+ function configureRunFetch(
338
+ annotations: AnnotationDto[] = [],
339
+ runOverrides: Partial<WorkflowRunDetailResponseDto> = {},
340
+ annotationPageOverrides: Partial<{has_more: boolean; next_cursor: string | null}> = {},
341
+ ) {
342
+ configureApiClient({
343
+ fetchImpl: vi.fn((input: RequestInfo | URL) =>
344
+ Promise.resolve(
345
+ requestUrl(input).includes('/annotations')
346
+ ? jsonResponse({
347
+ annotations,
348
+ has_more: false,
349
+ next_cursor: null,
350
+ ...annotationPageOverrides,
351
+ })
352
+ : jsonResponse(workflowRunViewDetailDto(runOverrides)),
353
+ ),
354
+ ),
1167
355
  });
1168
- const match = mockRequests(fetchImpl).find(
1169
- (request) => request.method === method && requestUrl(request).pathname === pathname,
1170
- );
1171
- if (!match) throw new Error(`Missing ${method} ${pathname}`);
1172
- return match;
1173
- }
1174
-
1175
- function mockRequests(fetchImpl: ReturnType<typeof vi.fn>): Request[] {
1176
- return (fetchImpl.mock.calls as unknown[][])
1177
- .map((call) => call[0])
1178
- .filter((input): input is Request => input instanceof Request);
1179
356
  }
1180
357
 
1181
- function locatedStepSourceDetail(
1182
- overrides: Partial<WorkflowRunDetailResponseDto> = {},
1183
- ): WorkflowRunDetailResponseDto {
1184
- return workflowRunViewDetailDto({
1185
- source_snapshot: {
1186
- format: 'yaml',
1187
- content: 'jobs:\n build:\n steps:\n - run: pnpm test',
1188
- },
1189
- jobs: [
1190
- workflowJobDto({
1191
- id: BUILD_JOB_ID,
1192
- name: 'build',
1193
- status: 'succeeded',
1194
- steps: [
1195
- workflowStepDto({
1196
- id: CHECKOUT_STEP_ID,
1197
- name: 'checkout',
1198
- status: 'succeeded',
1199
- source_location: {start_line: 2, end_line: 3},
1200
- attempts: [
1201
- workflowStepAttemptDto({
1202
- id: CHECKOUT_ATTEMPT_ID,
1203
- step_id: CHECKOUT_STEP_ID,
1204
- status: 'succeeded',
1205
- exit_code: 0,
1206
- finished_at: '2026-05-07T01:01:20.000Z',
1207
- }),
1208
- ],
1209
- }),
1210
- ],
1211
- }),
1212
- ],
358
+ function annotationDto(overrides: Partial<AnnotationDto> & {id: string}): AnnotationDto {
359
+ return {
360
+ job_id: BUILD_JOB_ID,
361
+ job_execution_id: BUILD_EXECUTION_ID,
362
+ origin_step_id: BUILD_STEP_ID,
363
+ origin_step_attempt: 1,
364
+ context: 'default',
365
+ style: 'default',
366
+ sequence: 1,
367
+ body: 'Body',
1213
368
  ...overrides,
1214
- });
369
+ };
1215
370
  }
1216
371
 
1217
372
  function workflowRunViewDetailDto(
@@ -1220,7 +375,6 @@ function workflowRunViewDetailDto(
1220
375
  return workflowRunDetailDto({
1221
376
  id: RUN_ID,
1222
377
  project_id: PROJECT_ID,
1223
- definition_id: DEFINITION_ID,
1224
378
  name: 'deploy-web',
1225
379
  status: 'running',
1226
380
  trigger_payload: {},
@@ -1232,19 +386,24 @@ function workflowRunViewDetailDto(
1232
386
  run_attempt_id: RUN_ID,
1233
387
  name: 'build',
1234
388
  status: 'succeeded',
1235
- steps: [
1236
- workflowStepDto({
1237
- id: CHECKOUT_STEP_ID,
1238
- key: 'checkout',
1239
- name: 'checkout',
389
+ job_executions: [
390
+ workflowJobExecutionDto({
391
+ id: BUILD_EXECUTION_ID,
392
+ job_id: BUILD_JOB_ID,
1240
393
  status: 'succeeded',
1241
- attempts: [
1242
- workflowStepAttemptDto({
1243
- id: CHECKOUT_ATTEMPT_ID,
1244
- step_id: CHECKOUT_STEP_ID,
394
+ steps: [
395
+ workflowStepDto({
396
+ id: BUILD_STEP_ID,
397
+ name: 'checkout',
1245
398
  status: 'succeeded',
1246
- exit_code: 0,
1247
- finished_at: '2026-05-07T01:01:20.000Z',
399
+ attempts: [
400
+ workflowStepAttemptDto({
401
+ id: BUILD_ATTEMPT_ID,
402
+ step_id: BUILD_STEP_ID,
403
+ attempt: 1,
404
+ status: 'succeeded',
405
+ }),
406
+ ],
1248
407
  }),
1249
408
  ],
1250
409
  }),
@@ -1257,14 +416,7 @@ function workflowRunViewDetailDto(
1257
416
  status: 'running',
1258
417
  position: 1,
1259
418
  dependencies: ['build'],
1260
- job_executions: [
1261
- workflowJobExecutionDto({
1262
- job_id: DEPLOY_JOB_ID,
1263
- name: 'deploy',
1264
- status: 'running',
1265
- steps: [workflowStepDto({status: 'running'})],
1266
- }),
1267
- ],
419
+ steps: [workflowStepDto({name: 'deploy', status: 'running'})],
1268
420
  }),
1269
421
  ],
1270
422
  ...overrides,