@shipfox/client-workflows 14.0.1 → 15.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 (223) hide show
  1. package/.storybook/preview.tsx +7 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  5. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  6. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  7. package/dist/components/job-detail/job-detail-header.d.ts +8 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  9. package/dist/components/job-detail/job-detail-header.js +111 -0
  10. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  11. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  13. package/dist/components/job-detail/job-detail-view.js +405 -0
  14. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  15. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  17. package/dist/components/job-detail/job-empty-states.js +195 -0
  18. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  19. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  21. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  23. package/dist/components/job-detail/job-selection.d.ts +24 -0
  24. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  25. package/dist/components/job-detail/job-selection.js +66 -0
  26. package/dist/components/job-detail/job-selection.js.map +1 -0
  27. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  29. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  30. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  31. package/dist/components/job-graph/job-graph-content.js +1 -1
  32. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  33. package/dist/components/step-list/step-list-model.js +2 -2
  34. package/dist/components/step-list/step-list-model.js.map +1 -1
  35. package/dist/components/step-list/step-list.d.ts +1 -0
  36. package/dist/components/step-list/step-list.d.ts.map +1 -1
  37. package/dist/components/step-list/step-list.js +29 -4
  38. package/dist/components/step-list/step-list.js.map +1 -1
  39. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  40. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  41. package/dist/components/workflow-run-duration-label.js +27 -7
  42. package/dist/components/workflow-run-duration-label.js.map +1 -1
  43. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  45. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  46. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.js +68 -64
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  49. package/dist/components/workflow-run-tabs/index.d.ts +0 -3
  50. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  51. package/dist/components/workflow-run-tabs/index.js +0 -3
  52. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  53. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  54. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  55. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  56. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  57. package/dist/components/workflow-run-view/index.d.ts +0 -1
  58. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/index.js +0 -1
  60. package/dist/components/workflow-run-view/index.js.map +1 -1
  61. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  62. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  63. package/dist/components/workflow-run-view/run-workspace-nav.js +271 -0
  64. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  65. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  66. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  67. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  68. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -8
  69. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  70. package/dist/components/workflow-run-view/workflow-run-view.js +250 -236
  71. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  72. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  73. package/dist/components/workflow-status/status-visuals.js +10 -2
  74. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  75. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  76. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  77. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  78. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  79. package/dist/core/entities/job-execution.d.ts +9 -0
  80. package/dist/core/entities/job-execution.d.ts.map +1 -1
  81. package/dist/core/entities/job-execution.js +13 -3
  82. package/dist/core/entities/job-execution.js.map +1 -1
  83. package/dist/core/entities/job.d.ts.map +1 -1
  84. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  85. package/dist/core/entities/step.d.ts +2 -0
  86. package/dist/core/entities/step.d.ts.map +1 -1
  87. package/dist/core/entities/step.js +13 -0
  88. package/dist/core/entities/step.js.map +1 -1
  89. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  90. package/dist/core/entities/workflow-run.d.ts +56 -1
  91. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  92. package/dist/core/entities/workflow-run.js +98 -0
  93. package/dist/core/entities/workflow-run.js.map +1 -1
  94. package/dist/core/workflow-run.d.ts +4 -4
  95. package/dist/core/workflow-run.d.ts.map +1 -1
  96. package/dist/core/workflow-run.js +3 -3
  97. package/dist/core/workflow-run.js.map +1 -1
  98. package/dist/feature.d.ts +20 -16
  99. package/dist/feature.d.ts.map +1 -1
  100. package/dist/feature.js +5 -0
  101. package/dist/feature.js.map +1 -1
  102. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  103. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  104. package/dist/hooks/api/workflow-runs.js +5 -4
  105. package/dist/hooks/api/workflow-runs.js.map +1 -1
  106. package/dist/index.d.ts +1 -0
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +1 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  111. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  112. package/dist/pages/workflow-job-detail-page.js +66 -0
  113. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  114. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  115. package/dist/pages/workflow-run-detail-page.js +4 -37
  116. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  117. package/dist/routes/inputs.d.ts +47 -34
  118. package/dist/routes/inputs.d.ts.map +1 -1
  119. package/dist/routes/inputs.js +69 -37
  120. package/dist/routes/inputs.js.map +1 -1
  121. package/dist/routes/job-detail.d.ts +10 -0
  122. package/dist/routes/job-detail.d.ts.map +1 -0
  123. package/dist/routes/job-detail.js +27 -0
  124. package/dist/routes/job-detail.js.map +1 -0
  125. package/dist/routes/project-route.d.ts +1 -1
  126. package/dist/routes/run-detail.d.ts.map +1 -1
  127. package/dist/routes/runs.d.ts.map +1 -1
  128. package/dist/routes/workflows.d.ts.map +1 -1
  129. package/dist/tsconfig.test.tsbuildinfo +1 -1
  130. package/package.json +3 -3
  131. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  132. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  133. package/src/components/job-detail/job-detail-header.test.tsx +31 -0
  134. package/src/components/job-detail/job-detail-header.tsx +111 -0
  135. package/src/components/job-detail/job-detail-view.stories.tsx +526 -0
  136. package/src/components/job-detail/job-detail-view.tsx +450 -0
  137. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  138. package/src/components/job-detail/job-empty-states.tsx +248 -0
  139. package/src/components/job-detail/job-selection.test.ts +188 -0
  140. package/src/components/job-detail/job-selection.ts +96 -0
  141. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  142. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  143. package/src/components/job-graph/job-graph-content.tsx +1 -1
  144. package/src/components/step-list/step-list-model.test.ts +1 -1
  145. package/src/components/step-list/step-list-model.ts +2 -2
  146. package/src/components/step-list/step-list.stories.tsx +1 -1
  147. package/src/components/step-list/step-list.test.tsx +33 -0
  148. package/src/components/step-list/step-list.tsx +40 -9
  149. package/src/components/workflow-run-duration-label.tsx +50 -12
  150. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  151. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  152. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  153. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  154. package/src/components/workflow-run-tabs/index.ts +0 -3
  155. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  156. package/src/components/workflow-run-view/index.ts +0 -1
  157. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +242 -0
  158. package/src/components/workflow-run-view/run-workspace-nav.tsx +301 -0
  159. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  160. package/src/components/workflow-run-view/workflow-run-view.test.tsx +62 -1136
  161. package/src/components/workflow-run-view/workflow-run-view.tsx +330 -321
  162. package/src/components/workflow-status/status-visuals.ts +5 -2
  163. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  164. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  165. package/src/core/entities/job-execution.ts +17 -2
  166. package/src/core/entities/step.ts +26 -0
  167. package/src/core/entities/workflow-run-display.test.ts +159 -0
  168. package/src/core/entities/workflow-run.ts +139 -1
  169. package/src/core/workflow-run.ts +16 -1
  170. package/src/feature.ts +5 -0
  171. package/src/hooks/api/workflow-runs.ts +6 -2
  172. package/src/index.ts +1 -0
  173. package/src/page-harness-budget.test.ts +1 -0
  174. package/src/pages/workflow-job-detail-page.test.tsx +539 -0
  175. package/src/pages/workflow-job-detail-page.tsx +84 -0
  176. package/src/pages/workflow-run-detail-page.tsx +3 -43
  177. package/src/pages/workflow-run-pages.test.tsx +77 -332
  178. package/src/routes/inputs.test.ts +56 -6
  179. package/src/routes/inputs.ts +64 -17
  180. package/src/routes/job-detail.tsx +29 -0
  181. package/test/pages.tsx +25 -3
  182. package/test/render.tsx +6 -1
  183. package/tsconfig.build.tsbuildinfo +1 -1
  184. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  185. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  186. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  187. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  188. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  189. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  190. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  191. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  192. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  193. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  194. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  195. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  196. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  197. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  198. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  199. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  200. package/dist/components/workflow-run-view/job-card.js +0 -496
  201. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  202. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  203. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  204. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  205. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  206. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  207. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  208. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  209. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  210. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  211. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  212. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  213. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  214. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  215. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  216. package/src/components/workflow-run-view/job-card.tsx +0 -614
  217. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  218. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  219. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  220. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  221. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  222. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  223. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -1,18 +1,10 @@
1
1
  import type {WorkflowRunDetailResponseDto} from '@shipfox/api-workflows-dto';
2
2
  import {configureApiClient} from '@shipfox/client-api';
3
- import {toast} from '@shipfox/react-ui/toast';
4
3
  import {screen, waitFor, within} from '@testing-library/react';
5
4
  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
5
  import {
9
- runAttemptsResponseDto,
10
6
  workflowJobDto,
11
- workflowJobExecutionDto,
12
- workflowRunAttemptDto,
13
7
  workflowRunDetailDto,
14
- workflowRunDto,
15
- workflowStepAttemptDto,
16
8
  workflowStepDto,
17
9
  } from '#test/fixtures/workflow-run.js';
18
10
  import {jsonResponse, PROJECT_TEST_WSLUG, renderProjectPage} from '#test/pages.js';
@@ -20,700 +12,113 @@ import {WorkflowRunView} from './workflow-run-view.js';
20
12
 
21
13
  const RUN_ID = '66666666-6666-4666-8666-666666666666';
22
14
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
23
- const DEFINITION_ID = '55555555-5555-4555-8555-555555555555';
24
15
  const BUILD_JOB_ID = '77777777-7777-4777-8777-777777777777';
25
16
  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('');
37
17
 
38
18
  describe('WorkflowRunView', () => {
39
- test('keeps the real tab strip visible while the run is loading', async () => {
19
+ beforeEach(() => {
20
+ Element.prototype.scrollIntoView = vi.fn();
21
+ });
22
+
23
+ test('reserves the shared run workspace while the run is loading', async () => {
40
24
  configureApiClient({fetchImpl: vi.fn(() => new Promise<Response>(() => undefined))});
41
25
 
42
- renderView({tab: 'summary'});
26
+ renderView();
43
27
 
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();
28
+ expect(await screen.findByRole('region', {name: 'Loading workflow run'})).toBeInTheDocument();
29
+ expect(screen.getByLabelText('Loading run navigation')).toBeInTheDocument();
48
30
  expect(screen.getByRole('region', {name: 'Loading workflow run content'})).toBeInTheDocument();
31
+ expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
49
32
  });
50
33
 
51
- test('renders the run summary, jobs list, and selected job step attempts when a run loads', async () => {
34
+ test('opens the all-jobs Summary on the dependency graph by default', async () => {
52
35
  configureApiClient({
53
36
  fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
54
37
  });
55
38
 
56
- renderView();
39
+ const {container} = renderView();
57
40
 
58
41
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
59
-
60
42
  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();
43
+ expect(screen.getByRole('navigation', {name: 'Run workspace'})).toBeInTheDocument();
63
44
  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',
45
+ screen
46
+ .getByRole('navigation', {name: 'Run workspace'})
47
+ .closest('[data-run-workspace-layout]'),
48
+ ).toHaveClass('border-t', 'border-border-neutral-base', 'min-[768px]:flex-row');
49
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
50
+ expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
51
+ expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
52
+ expect(screen.getByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
53
+ expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
54
+ expect(container.querySelector('[data-run-workspace-content]')).toHaveClass(
55
+ 'bg-background-neutral-base',
56
+ 'flex-1',
72
57
  );
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();
58
+ expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
78
59
  });
79
60
 
80
- test('uses the selected job execution name as the job card title', async () => {
61
+ test('treats the removed Jobs tab URL as the graph Summary', async () => {
81
62
  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
- });
104
-
105
- renderView();
106
-
107
- expect(await screen.findByRole('region', {name: 'Implement ENG-123'})).toBeInTheDocument();
108
- expect(screen.queryByRole('region', {name: JOB_TEMPLATE_NAME})).not.toBeInTheDocument();
109
- });
110
-
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
- );
63
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
149
64
  });
150
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
151
65
 
152
- renderView();
66
+ renderView({tab: 'jobs'});
153
67
 
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');
68
+ expect(await screen.findByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
69
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
159
70
  });
160
71
 
161
- test('switches executions from the listening job header', async () => {
72
+ test('navigates graph and rail jobs to their dedicated job routes', async () => {
162
73
  const user = userEvent.setup();
163
74
  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
- });
268
-
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}));
281
-
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();
288
- });
289
-
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
- );
75
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
349
76
  });
350
- configureApiClient({fetchImpl: fetchImpl as typeof fetch});
351
77
 
352
- renderView();
353
- await user.click(
354
- await screen.findByRole('button', {
355
- name: 'Fix the failing tests., Failed, attempt 1, User',
356
- }),
357
- );
78
+ const {router} = renderView();
79
+ await user.click(await screen.findByRole('button', {name: 'deploy, Running'}));
358
80
 
359
- 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.',
81
+ await waitFor(() =>
82
+ expect(router.state.location.pathname).toBe(
83
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}/jobs/${DEPLOY_JOB_ID}`,
369
84
  ),
370
- ).toBeInTheDocument();
371
- expect(screen.getByRole('link', {name: 'Configure Agents'})).toHaveAttribute(
372
- 'href',
373
- `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
374
85
  );
375
86
  });
376
87
 
377
- test('falls back to the step error when an agent attempt has no typed error', async () => {
88
+ test('keeps run Annotations and Source in the workspace navigation', async () => {
378
89
  const user = userEvent.setup();
379
- const stepId = '99999999-9999-4999-8999-000000000005';
380
- const attemptId = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000005';
381
90
  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
- );
434
- }),
435
- });
436
-
437
- renderView();
438
- await user.click(
439
- await screen.findByRole('button', {
440
- name: 'Fix the failing tests., Failed, attempt 1, User',
441
- }),
442
- );
443
-
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
- );
452
- });
453
-
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
- });
475
-
476
- renderView();
477
-
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
- });
484
-
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
- ),
513
- });
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
- });
521
-
522
- test('renders pending zero-step jobs as waiting to start', async () => {
523
- configureApiClient({
524
- fetchImpl: vi.fn(() =>
525
- 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
- ),
539
- ),
540
- ),
541
- });
542
-
543
- renderView();
544
-
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();
548
- });
549
-
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
- ),
574
- ),
575
- });
576
-
577
- renderView();
578
-
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();
587
- });
588
-
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
- ),
609
- ),
610
- });
611
-
612
- renderView();
613
-
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();
619
- });
620
-
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
- });
641
-
642
- renderView();
643
-
644
- expect(await screen.findByText('Execution details unavailable')).toBeInTheDocument();
645
- expect(
646
- screen.getByText('This job finished, but no job execution record is available.'),
647
- ).toBeInTheDocument();
648
- expect(screen.queryByText('Waiting for trigger events')).not.toBeInTheDocument();
649
- });
650
-
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
- });
677
-
678
- renderView();
679
-
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();
683
- });
684
-
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}))),
91
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
688
92
  });
689
93
 
690
- renderView();
94
+ const {router} = renderView();
95
+ await screen.findByRole('region', {name: 'Workflow jobs'});
96
+ await user.click(screen.getByRole('link', {name: 'Annotations'}));
691
97
 
692
- expect(await screen.findByText('Run not found')).toBeInTheDocument();
98
+ await waitFor(() => expect(router.state.location.search).toMatchObject({tab: 'annotations'}));
693
99
  });
694
100
 
695
- test('shows the load-error placeholder when the run fails to load', async () => {
101
+ test('filters the single run-level Annotations page by job', async () => {
696
102
  configureApiClient({
697
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}))),
103
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
698
104
  });
699
105
 
700
- renderView();
106
+ renderView({tab: 'annotations', selection: {jobId: BUILD_JOB_ID}});
701
107
 
702
108
  expect(
703
- await screen.findByRole('button', {name: 'Retry loading workflow run'}),
704
- ).toBeInTheDocument();
109
+ await screen.findByRole('combobox', {name: 'Filter annotations by job'}),
110
+ ).toHaveTextContent('build');
111
+ expect(screen.getByText('build has no annotations to show in this run.')).toBeInTheDocument();
112
+ expect(screen.queryByRole('tab', {name: 'Annotations'})).not.toBeInTheDocument();
705
113
  });
706
114
 
707
- test('renders the full workflow source in the Source tab', async () => {
115
+ test('renders the captured workflow source in the Source section', async () => {
708
116
  configureApiClient({
709
117
  fetchImpl: vi.fn(() =>
710
118
  Promise.resolve(
711
119
  jsonResponse(
712
120
  workflowRunViewDetailDto({
713
- source_snapshot: {
714
- format: 'yaml',
715
- content: 'jobs:\n build:\n steps:\n - run: pnpm test',
716
- },
121
+ source_snapshot: {format: 'yaml', content: 'jobs:\n build:\n steps: []'},
717
122
  }),
718
123
  ),
719
124
  ),
@@ -722,505 +127,49 @@ describe('WorkflowRunView', () => {
722
127
 
723
128
  renderView({tab: 'source'});
724
129
 
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:');
130
+ expect(await screen.findByText('build:')).toBeInTheDocument();
131
+ expect(screen.getByRole('link', {name: 'Source'})).toHaveAttribute('aria-current', 'page');
792
132
  });
793
133
 
794
134
  test('explains when the run has no source snapshot', async () => {
795
135
  configureApiClient({
796
- fetchImpl: vi.fn(() =>
797
- Promise.resolve(jsonResponse(workflowRunViewDetailDto({source_snapshot: null}))),
798
- ),
136
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
799
137
  });
800
138
 
801
139
  renderView({tab: 'source'});
802
140
 
803
- await screen.findByRole('region', {name: 'deploy-web'});
804
-
805
141
  expect(await screen.findByText('Source snapshot unavailable')).toBeInTheDocument();
806
142
  });
807
143
 
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 () => {
144
+ test('shows the not-found surface when the run 404s', async () => {
1084
145
  configureApiClient({
1085
- fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(workflowRunViewDetailDto()))),
146
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}))),
1086
147
  });
1087
148
 
1088
149
  renderView();
1089
150
 
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);
151
+ expect(await screen.findByText('Run not found')).toBeInTheDocument();
1136
152
  });
1137
153
  });
1138
154
 
1139
- function renderView(props: Partial<Parameters<typeof WorkflowRunView>[0]> = {}, search = '') {
1140
- return renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}${search}`, () => (
155
+ function renderView(props: Partial<Parameters<typeof WorkflowRunView>[0]> = {}) {
156
+ return renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}`, () => (
1141
157
  <WorkflowRunView
1142
158
  projectId={PROJECT_ID}
1143
159
  workspaceSlug={PROJECT_TEST_WSLUG}
1144
160
  projectSlug="project"
1145
161
  workflowRunId={RUN_ID}
1146
- tab="jobs"
1147
162
  {...props}
1148
163
  />
1149
164
  ));
1150
165
  }
1151
166
 
1152
- function requestUrl(input: RequestInfo | URL): URL {
1153
- if (input instanceof Request) return new URL(input.url);
1154
- return new URL(String(input));
1155
- }
1156
-
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();
1167
- });
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
- }
1180
-
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
- ],
1213
- ...overrides,
1214
- });
1215
- }
1216
-
1217
167
  function workflowRunViewDetailDto(
1218
168
  overrides: Partial<WorkflowRunDetailResponseDto> = {},
1219
169
  ): WorkflowRunDetailResponseDto {
1220
170
  return workflowRunDetailDto({
1221
171
  id: RUN_ID,
1222
172
  project_id: PROJECT_ID,
1223
- definition_id: DEFINITION_ID,
1224
173
  name: 'deploy-web',
1225
174
  status: 'running',
1226
175
  trigger_payload: {},
@@ -1232,23 +181,7 @@ function workflowRunViewDetailDto(
1232
181
  run_attempt_id: RUN_ID,
1233
182
  name: 'build',
1234
183
  status: 'succeeded',
1235
- steps: [
1236
- workflowStepDto({
1237
- id: CHECKOUT_STEP_ID,
1238
- key: 'checkout',
1239
- name: 'checkout',
1240
- status: 'succeeded',
1241
- attempts: [
1242
- workflowStepAttemptDto({
1243
- id: CHECKOUT_ATTEMPT_ID,
1244
- step_id: CHECKOUT_STEP_ID,
1245
- status: 'succeeded',
1246
- exit_code: 0,
1247
- finished_at: '2026-05-07T01:01:20.000Z',
1248
- }),
1249
- ],
1250
- }),
1251
- ],
184
+ steps: [workflowStepDto({name: 'checkout', status: 'succeeded'})],
1252
185
  }),
1253
186
  workflowJobDto({
1254
187
  id: DEPLOY_JOB_ID,
@@ -1257,14 +190,7 @@ function workflowRunViewDetailDto(
1257
190
  status: 'running',
1258
191
  position: 1,
1259
192
  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
- ],
193
+ steps: [workflowStepDto({name: 'deploy', status: 'running'})],
1268
194
  }),
1269
195
  ],
1270
196
  ...overrides,