@shipfox/client-workflows 14.0.0 → 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 +35 -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 +7 -7
  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
@@ -0,0 +1,539 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {act, screen} from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import {workflowRunsQueryKeys} from '#hooks/api/workflow-runs.js';
5
+ import type {WorkflowJobSearch, WorkflowRunsSearch} from '#routes/inputs.js';
6
+ import {
7
+ runAttemptsResponseDto,
8
+ workflowJobDto,
9
+ workflowJobExecutionDto,
10
+ workflowRunAttemptDto,
11
+ workflowRunDetailDto,
12
+ workflowStepAttemptDto,
13
+ workflowStepDto,
14
+ } from '#test/fixtures/workflow-run.js';
15
+ import {jsonResponse, PROJECT_TEST_WSLUG, renderProjectPage} from '#test/pages.js';
16
+ import {WorkflowJobDetailPage} from './workflow-job-detail-page.js';
17
+ import {WorkflowRunDetailPage} from './workflow-run-detail-page.js';
18
+
19
+ const RUN_ID = '66666666-6666-4666-8666-666666666666';
20
+ const JOB_ID = '88888888-8888-4888-8888-888888888888';
21
+ const EXECUTION_ID = '77777777-7777-4777-8777-777777777777';
22
+ const STEP_ID = '99999999-9999-4999-8999-999999999999';
23
+ const ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
24
+ const LIVE_JOB_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
25
+ const LIVE_EXECUTION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
26
+ const LIVE_BUILD_STEP_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
27
+ const LIVE_DEPLOY_STEP_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
28
+ const LIVE_BUILD_ATTEMPT_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
29
+ const LIVE_DEPLOY_ATTEMPT_ID = '11111111-2222-4333-8444-555555555555';
30
+ const NEWER_JOB_ID = '22222222-3333-4444-8555-666666666666';
31
+ const SIBLING_JOB_ID = '33333333-4444-4555-8666-777777777777';
32
+ const BACK_TO_SUMMARY_PATTERN = /Back to run summary/;
33
+ const RUN_MOVED_ON_PATTERN = /Run moved on to/;
34
+ const LINT_LINK_PATTERN = /lint/;
35
+ const RELEASE_LINK_PATTERN = /release/;
36
+
37
+ describe('WorkflowJobDetailPage', () => {
38
+ test('resolves an exact job execution and step attempt from a deep link', async () => {
39
+ configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
40
+
41
+ renderJobPath(
42
+ `?jobExecution=${EXECUTION_ID}&step=${STEP_ID}&stepAttempt=${ATTEMPT_ID}&runAttempt=1`,
43
+ );
44
+
45
+ expect(await screen.findByRole('heading', {name: 'release'})).toHaveFocus();
46
+ expect(
47
+ screen.getByRole('button', {
48
+ name: 'Switch job execution, currently execution 2: release',
49
+ }),
50
+ ).toBeInTheDocument();
51
+ expect(
52
+ await screen.findByRole('region', {name: 'tests output, attempt 2'}),
53
+ ).toBeInTheDocument();
54
+ expect(screen.getByRole('region', {name: 'release logs'})).toBeInTheDocument();
55
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
56
+ expect(screen.getByRole('img', {name: 'Job status: Succeeded'})).toBeInTheDocument();
57
+ expect(screen.getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
58
+ expect(screen.getByRole('navigation', {name: 'Run workspace'})).toBeInTheDocument();
59
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute(
60
+ 'href',
61
+ expect.stringContaining(`runs/${RUN_ID}?runAttempt=%221%22`),
62
+ );
63
+ expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
64
+ expect(screen.getByRole('link', {name: RELEASE_LINK_PATTERN})).toHaveAttribute(
65
+ 'aria-current',
66
+ 'page',
67
+ );
68
+ expect(screen.getByText('5s')).toBeInTheDocument();
69
+ expect(screen.getAllByText('1m 10s')).not.toHaveLength(0);
70
+ });
71
+
72
+ test('shows a retarget notice when polling advances to the next running step', async () => {
73
+ let detailRequestCount = 0;
74
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
75
+ const url = new URL((input as Request).url);
76
+ if (url.pathname.includes('/logs')) {
77
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
78
+ }
79
+ detailRequestCount += 1;
80
+ return Promise.resolve(
81
+ jsonResponse(
82
+ detailRequestCount === 1 ? liveInitialJobDetailDto() : liveAdvancedJobDetailDto(),
83
+ ),
84
+ );
85
+ });
86
+ configureApiClient({fetchImpl: fetchImpl as typeof fetch});
87
+
88
+ const {queryClient} = renderJobPath('?runAttempt=1', LIVE_JOB_ID);
89
+ expect(
90
+ await screen.findByRole('region', {name: 'build output, attempt 1'}),
91
+ ).toBeInTheDocument();
92
+
93
+ await act(async () => {
94
+ await queryClient.invalidateQueries({queryKey: workflowRunsQueryKeys.detail(RUN_ID, 1)});
95
+ });
96
+
97
+ expect(await screen.findByText(RUN_MOVED_ON_PATTERN)).toBeInTheDocument();
98
+ expect(screen.getAllByText('deploy')).not.toHaveLength(0);
99
+ });
100
+
101
+ test('shows a retarget notice when polling advances the running attempt', async () => {
102
+ let detailRequestCount = 0;
103
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
104
+ const url = new URL((input as Request).url);
105
+ if (url.pathname.includes('/logs')) {
106
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
107
+ }
108
+ detailRequestCount += 1;
109
+ return Promise.resolve(
110
+ jsonResponse(
111
+ detailRequestCount === 1
112
+ ? liveRetriedInitialJobDetailDto()
113
+ : liveRetriedAdvancedJobDetailDto(),
114
+ ),
115
+ );
116
+ });
117
+ configureApiClient({fetchImpl: fetchImpl as typeof fetch});
118
+
119
+ const {queryClient} = renderJobPath('?runAttempt=1', LIVE_JOB_ID);
120
+ expect(
121
+ await screen.findByRole('region', {name: 'build output, attempt 1'}),
122
+ ).toBeInTheDocument();
123
+
124
+ await act(async () => {
125
+ await queryClient.invalidateQueries({queryKey: workflowRunsQueryKeys.detail(RUN_ID, 1)});
126
+ });
127
+
128
+ expect(await screen.findByText(RUN_MOVED_ON_PATTERN)).toBeInTheDocument();
129
+ });
130
+
131
+ test('moves focus to the heading after navigating from the job rail', async () => {
132
+ const user = userEvent.setup();
133
+ configureApiClient({
134
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(multiJobDetailDto()))),
135
+ });
136
+
137
+ renderJobPath();
138
+ await screen.findByRole('heading', {name: 'release'});
139
+
140
+ await user.click(screen.getByRole('link', {name: LINT_LINK_PATTERN}));
141
+
142
+ expect(await screen.findByRole('heading', {name: 'lint'})).toHaveFocus();
143
+ });
144
+
145
+ test('shows a newer-attempt notice when the run has a newer attempt', async () => {
146
+ configureApiClient({fetchImpl: vi.fn(newerAttemptJobDetailFetch)});
147
+
148
+ renderJobPath();
149
+
150
+ expect(await screen.findByText('A newer run attempt is available.')).toBeInTheDocument();
151
+ expect(screen.getByRole('link', {name: 'View attempt #2'})).toBeInTheDocument();
152
+ });
153
+
154
+ test('renders the job-not-found state for a job outside the run', async () => {
155
+ configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
156
+
157
+ renderJobPath('', 'missing-job');
158
+
159
+ expect(await screen.findByText('Job not found')).toBeInTheDocument();
160
+ expect(screen.getByRole('link', {name: BACK_TO_SUMMARY_PATTERN})).toHaveAttribute(
161
+ 'href',
162
+ expect.stringContaining(`runs/${RUN_ID}`),
163
+ );
164
+ });
165
+
166
+ test('treats the removed job Annotations URL as the job log document', async () => {
167
+ configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
168
+
169
+ const {router} = renderJobPath('?runAttempt=1&tab=annotations');
170
+ await screen.findByRole('heading', {name: 'release'});
171
+
172
+ expect(router.state.location.pathname).toBe(
173
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}/jobs/${JOB_ID}`,
174
+ );
175
+ expect(router.state.location.search).toEqual({runAttempt: 1, tab: 'annotations'});
176
+ expect(screen.getByRole('region', {name: 'release logs'})).toBeInTheDocument();
177
+ expect(screen.queryByText('This job has no annotations to show.')).not.toBeInTheDocument();
178
+ });
179
+
180
+ test('explains a failure that happened before the first step started', async () => {
181
+ configureApiClient({
182
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse(failedBeforeStepsDetailDto()))),
183
+ });
184
+
185
+ renderJobPath();
186
+
187
+ expect(await screen.findByText('Job failed before its first step started')).toBeInTheDocument();
188
+ expect(
189
+ screen.getByText(
190
+ 'The runner stopped responding before work began. Check runner availability before re-running the workflow.',
191
+ ),
192
+ ).toBeInTheDocument();
193
+ });
194
+
195
+ test('browser back from the job page returns to the run page', async () => {
196
+ configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
197
+
198
+ const {router} = renderJobPath();
199
+ await screen.findByRole('heading', {name: 'release'});
200
+ await act(async () => {
201
+ await router.navigate({
202
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
203
+ params: {
204
+ workspaceSlug: PROJECT_TEST_WSLUG,
205
+ projectSlug: 'project',
206
+ workflowRunId: RUN_ID,
207
+ },
208
+ });
209
+ await router.navigate({
210
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId/jobs/$jobId',
211
+ params: {
212
+ workspaceSlug: PROJECT_TEST_WSLUG,
213
+ projectSlug: 'project',
214
+ workflowRunId: RUN_ID,
215
+ jobId: JOB_ID,
216
+ },
217
+ });
218
+ router.history.back();
219
+ });
220
+
221
+ expect(await screen.findByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
222
+ });
223
+ });
224
+
225
+ function renderJobPath(path = '', jobId = JOB_ID) {
226
+ return renderProjectPage(
227
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}/jobs/${jobId}${path}`,
228
+ ({search, workflowRunId, jobId: routeJobId}) =>
229
+ routeJobId ? (
230
+ <WorkflowJobDetailPage
231
+ projectId="44444444-4444-4444-8444-444444444444"
232
+ workspaceSlug={PROJECT_TEST_WSLUG}
233
+ projectSlug="project"
234
+ workflowRunId={workflowRunId ?? RUN_ID}
235
+ jobId={routeJobId}
236
+ search={search as WorkflowJobSearch}
237
+ />
238
+ ) : (
239
+ <WorkflowRunDetailPage
240
+ projectId="44444444-4444-4444-8444-444444444444"
241
+ workspaceSlug={PROJECT_TEST_WSLUG}
242
+ projectSlug="project"
243
+ workflowRunId={workflowRunId ?? RUN_ID}
244
+ search={search as WorkflowRunsSearch}
245
+ />
246
+ ),
247
+ );
248
+ }
249
+
250
+ function jobDetailFetch(input: RequestInfo | URL) {
251
+ const request = input as Request;
252
+ const url = new URL(request.url);
253
+
254
+ if (url.pathname.endsWith('/attempts')) {
255
+ return Promise.resolve(
256
+ jsonResponse(
257
+ runAttemptsResponseDto({
258
+ attempts: [
259
+ workflowRunAttemptDto({
260
+ workflow_run_id: RUN_ID,
261
+ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
262
+ status: 'succeeded',
263
+ }),
264
+ ],
265
+ }),
266
+ ),
267
+ );
268
+ }
269
+
270
+ if (url.pathname.includes('/logs')) {
271
+ return Promise.resolve(
272
+ jsonResponse({
273
+ mode: 'inline',
274
+ ndjson: `${JSON.stringify({v: 1, ts: 1782043200000, type: 'output', stream: 'stdout', data: 'tests passed\\n'})}\\n`,
275
+ next_cursor: 0,
276
+ has_more: false,
277
+ state: 'closed',
278
+ truncated: false,
279
+ }),
280
+ );
281
+ }
282
+
283
+ return Promise.resolve(jsonResponse(jobDetailDto()));
284
+ }
285
+
286
+ function newerAttemptJobDetailFetch(input: RequestInfo | URL) {
287
+ const request = input as Request;
288
+ const url = new URL(request.url);
289
+
290
+ if (url.pathname.endsWith('/attempts')) {
291
+ return Promise.resolve(
292
+ jsonResponse(
293
+ runAttemptsResponseDto({
294
+ attempts: [
295
+ workflowRunAttemptDto({workflow_run_id: RUN_ID, attempt: 1, status: 'succeeded'}),
296
+ workflowRunAttemptDto({workflow_run_id: RUN_ID, attempt: 2, status: 'succeeded'}),
297
+ ],
298
+ }),
299
+ ),
300
+ );
301
+ }
302
+
303
+ if (url.searchParams.get('attempt') === '2') {
304
+ return Promise.resolve(
305
+ jsonResponse(
306
+ workflowRunDetailDto({
307
+ id: RUN_ID,
308
+ status: 'succeeded',
309
+ latest_attempt: 2,
310
+ current_attempt: 2,
311
+ run_attempt: workflowRunAttemptDto({
312
+ workflow_run_id: RUN_ID,
313
+ attempt: 2,
314
+ status: 'succeeded',
315
+ }),
316
+ jobs: [
317
+ workflowJobDto({
318
+ id: NEWER_JOB_ID,
319
+ key: 'release',
320
+ name: 'release',
321
+ status: 'succeeded',
322
+ }),
323
+ ],
324
+ }),
325
+ ),
326
+ );
327
+ }
328
+
329
+ return Promise.resolve(jsonResponse(jobDetailDto()));
330
+ }
331
+
332
+ function liveInitialJobDetailDto() {
333
+ return liveJobDetailDto({buildStatus: 'running', deployStatus: 'pending'});
334
+ }
335
+
336
+ function liveAdvancedJobDetailDto() {
337
+ return liveJobDetailDto({buildStatus: 'succeeded', deployStatus: 'running'});
338
+ }
339
+
340
+ function liveRetriedInitialJobDetailDto() {
341
+ return liveJobDetailDto({
342
+ buildStatus: 'running',
343
+ deployStatus: 'pending',
344
+ buildAttempt: 1,
345
+ buildAttemptId: LIVE_BUILD_ATTEMPT_ID,
346
+ });
347
+ }
348
+
349
+ function liveRetriedAdvancedJobDetailDto() {
350
+ return liveJobDetailDto({
351
+ buildStatus: 'running',
352
+ deployStatus: 'pending',
353
+ buildAttempt: 2,
354
+ buildAttemptId: '22222222-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
355
+ });
356
+ }
357
+
358
+ function liveJobDetailDto({
359
+ buildStatus,
360
+ deployStatus,
361
+ buildAttempt = 1,
362
+ buildAttemptId = LIVE_BUILD_ATTEMPT_ID,
363
+ }: {
364
+ buildStatus: 'running' | 'succeeded';
365
+ deployStatus: 'pending' | 'running';
366
+ buildAttempt?: number;
367
+ buildAttemptId?: string;
368
+ }) {
369
+ const buildStep = workflowStepDto({
370
+ id: LIVE_BUILD_STEP_ID,
371
+ key: 'build',
372
+ name: 'build',
373
+ position: 0,
374
+ status: buildStatus,
375
+ current_attempt: buildAttempt,
376
+ attempts: [
377
+ workflowStepAttemptDto({
378
+ id: buildAttemptId,
379
+ step_id: LIVE_BUILD_STEP_ID,
380
+ attempt: buildAttempt,
381
+ status: buildStatus,
382
+ }),
383
+ ],
384
+ });
385
+ const deployStep = workflowStepDto({
386
+ id: LIVE_DEPLOY_STEP_ID,
387
+ key: 'deploy',
388
+ name: 'deploy',
389
+ position: 1,
390
+ status: deployStatus,
391
+ attempts:
392
+ deployStatus === 'running'
393
+ ? [
394
+ workflowStepAttemptDto({
395
+ id: LIVE_DEPLOY_ATTEMPT_ID,
396
+ step_id: LIVE_DEPLOY_STEP_ID,
397
+ attempt: 1,
398
+ status: 'running',
399
+ }),
400
+ ]
401
+ : [],
402
+ });
403
+
404
+ return workflowRunDetailDto({
405
+ id: RUN_ID,
406
+ status: 'running',
407
+ current_attempt: 1,
408
+ latest_attempt: 1,
409
+ run_attempt: workflowRunAttemptDto({
410
+ workflow_run_id: RUN_ID,
411
+ attempt: 1,
412
+ status: 'running',
413
+ }),
414
+ jobs: [
415
+ workflowJobDto({
416
+ id: LIVE_JOB_ID,
417
+ key: 'release',
418
+ name: 'release',
419
+ status: 'running',
420
+ job_executions: [
421
+ workflowJobExecutionDto({
422
+ id: LIVE_EXECUTION_ID,
423
+ job_id: LIVE_JOB_ID,
424
+ sequence: 1,
425
+ status: 'running',
426
+ steps: [buildStep, deployStep],
427
+ }),
428
+ ],
429
+ }),
430
+ ],
431
+ });
432
+ }
433
+
434
+ function jobDetailDto() {
435
+ const step = workflowStepDto({
436
+ id: STEP_ID,
437
+ name: 'tests',
438
+ key: 'tests',
439
+ status: 'succeeded',
440
+ attempts: [
441
+ workflowStepAttemptDto({
442
+ id: ATTEMPT_ID,
443
+ step_id: STEP_ID,
444
+ attempt: 2,
445
+ status: 'succeeded',
446
+ }),
447
+ ],
448
+ });
449
+ const execution = workflowJobExecutionDto({
450
+ id: EXECUTION_ID,
451
+ job_id: JOB_ID,
452
+ sequence: 2,
453
+ name: 'release',
454
+ status: 'succeeded',
455
+ queued_at: '2026-06-21T12:00:00.000Z',
456
+ started_at: '2026-06-21T12:00:05.000Z',
457
+ finished_at: '2026-06-21T12:01:15.000Z',
458
+ steps: [step],
459
+ });
460
+
461
+ return workflowRunDetailDto({
462
+ id: RUN_ID,
463
+ status: 'succeeded',
464
+ current_attempt: 1,
465
+ latest_attempt: 1,
466
+ run_attempt: workflowRunAttemptDto({
467
+ id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
468
+ workflow_run_id: RUN_ID,
469
+ attempt: 1,
470
+ status: 'succeeded',
471
+ }),
472
+ jobs: [
473
+ workflowJobDto({
474
+ id: JOB_ID,
475
+ key: 'release',
476
+ name: 'release',
477
+ status: 'succeeded',
478
+ job_executions: [
479
+ workflowJobExecutionDto({
480
+ id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
481
+ job_id: JOB_ID,
482
+ sequence: 1,
483
+ name: 'release',
484
+ status: 'failed',
485
+ }),
486
+ execution,
487
+ ],
488
+ }),
489
+ ],
490
+ });
491
+ }
492
+
493
+ function failedBeforeStepsDetailDto() {
494
+ return workflowRunDetailDto({
495
+ id: RUN_ID,
496
+ status: 'failed',
497
+ run_attempt: workflowRunAttemptDto({
498
+ workflow_run_id: RUN_ID,
499
+ attempt: 1,
500
+ status: 'failed',
501
+ }),
502
+ jobs: [
503
+ workflowJobDto({
504
+ id: JOB_ID,
505
+ key: 'release',
506
+ name: 'release',
507
+ status: 'failed',
508
+ status_reason: 'runner_lost',
509
+ job_executions: [
510
+ workflowJobExecutionDto({
511
+ id: EXECUTION_ID,
512
+ job_id: JOB_ID,
513
+ sequence: 1,
514
+ status: 'failed',
515
+ status_reason: 'runner_lost',
516
+ steps: [],
517
+ }),
518
+ ],
519
+ }),
520
+ ],
521
+ });
522
+ }
523
+
524
+ function multiJobDetailDto() {
525
+ const detail = jobDetailDto();
526
+ return {
527
+ ...detail,
528
+ jobs: [
529
+ ...detail.jobs,
530
+ workflowJobDto({
531
+ id: SIBLING_JOB_ID,
532
+ key: 'lint',
533
+ name: 'lint',
534
+ status: 'succeeded',
535
+ position: 1,
536
+ }),
537
+ ],
538
+ };
539
+ }
@@ -0,0 +1,84 @@
1
+ import {useNavigate} from '@tanstack/react-router';
2
+ import {useCallback, useMemo} from 'react';
3
+ import {JobDetailView} from '#components/job-detail/job-detail-view.js';
4
+ import {WorkflowRunView} from '#components/workflow-run-view/index.js';
5
+ import {isWorkflowRunTerminal} from '#core/workflow-run.js';
6
+ import {useWorkflowRunAttemptQuery, useWorkflowRunAttemptsQuery} from '#hooks/api/workflow-runs.js';
7
+ import {type WorkflowJobSearch, workflowJobSearchParams} from '#routes/inputs.js';
8
+
9
+ export interface WorkflowJobDetailPageProps {
10
+ projectId: string;
11
+ workspaceSlug: string;
12
+ projectSlug: string;
13
+ workflowRunId: string;
14
+ jobId: string;
15
+ search?: WorkflowJobSearch;
16
+ }
17
+
18
+ export function WorkflowJobDetailPage({
19
+ projectId,
20
+ workspaceSlug,
21
+ projectSlug,
22
+ workflowRunId,
23
+ jobId,
24
+ search = {},
25
+ }: WorkflowJobDetailPageProps) {
26
+ const navigate = useNavigate();
27
+ const runQuery = useWorkflowRunAttemptQuery({
28
+ workflowRunId,
29
+ runAttempt: search.runAttempt,
30
+ });
31
+ const attemptsQuery = useWorkflowRunAttemptsQuery({
32
+ workflowRunId,
33
+ enabled: Boolean(runQuery.data && isWorkflowRunTerminal(runQuery.data.runAttempt.status)),
34
+ });
35
+ const newerAttempt = useMemo(() => {
36
+ const currentAttempt = runQuery.data?.runAttempt.attempt ?? search.runAttempt ?? 1;
37
+ return attemptsQuery.data
38
+ ?.filter((attempt) => attempt.attempt > currentAttempt)
39
+ .sort((left, right) => right.attempt - left.attempt)[0]?.attempt;
40
+ }, [attemptsQuery.data, runQuery.data?.runAttempt.attempt, search.runAttempt]);
41
+ const newerRunQuery = useWorkflowRunAttemptQuery({
42
+ workflowRunId,
43
+ runAttempt: newerAttempt,
44
+ enabled: newerAttempt !== undefined,
45
+ });
46
+
47
+ const currentJobKey = runQuery.data?.jobs.find((job) => job.id === jobId)?.key;
48
+ const newerJob = currentJobKey
49
+ ? newerRunQuery.data?.jobs.find((job) => job.key === currentJobKey)
50
+ : undefined;
51
+
52
+ const onSelectionChange = useCallback(
53
+ (nextSelection: WorkflowJobSearch) => {
54
+ navigate({search: workflowJobSearchParams(nextSelection) as never});
55
+ },
56
+ [navigate],
57
+ );
58
+ return (
59
+ <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
60
+ <WorkflowRunView
61
+ projectId={projectId}
62
+ workspaceSlug={workspaceSlug}
63
+ projectSlug={projectSlug}
64
+ workflowRunId={workflowRunId}
65
+ runAttempt={search.runAttempt}
66
+ activeJobId={jobId}
67
+ jobSearch={search}
68
+ jobContent={
69
+ <JobDetailView
70
+ workspaceSlug={workspaceSlug}
71
+ projectSlug={projectSlug}
72
+ workflowRunId={workflowRunId}
73
+ jobId={jobId}
74
+ search={search}
75
+ query={runQuery}
76
+ newerAttempt={newerAttempt}
77
+ newerJob={newerJob}
78
+ onSelectionChange={onSelectionChange}
79
+ />
80
+ }
81
+ />
82
+ </div>
83
+ );
84
+ }
@@ -1,14 +1,5 @@
1
- import {useNavigate} from '@tanstack/react-router';
2
- import {useCallback} from 'react';
3
1
  import {WorkflowRunView} from '#components/workflow-run-view/index.js';
4
- import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
5
- import {
6
- validateWorkflowRunsSearch,
7
- type WorkflowRunsSearch,
8
- type WorkflowRunTab,
9
- workflowRunSearchParams,
10
- workflowRunTab,
11
- } from '#routes/inputs.js';
2
+ import {type WorkflowRunsSearch, workflowRunTab} from '#routes/inputs.js';
12
3
 
13
4
  interface WorkflowRunDetailPageProps {
14
5
  projectId: string;
@@ -25,36 +16,6 @@ export function WorkflowRunDetailPage({
25
16
  workflowRunId,
26
17
  search = {},
27
18
  }: WorkflowRunDetailPageProps) {
28
- const navigate = useNavigate();
29
- const selection: WorkflowRunSelectionInput = search;
30
- const onSelectionChange = useCallback(
31
- (nextSelection: WorkflowRunSelectionInput) => {
32
- navigate({
33
- search: ((previous: Record<string, unknown>) => {
34
- const current = validateWorkflowRunsSearch(previous);
35
- return workflowRunSearchParams(current, nextSelection);
36
- }) as never,
37
- replace: true,
38
- });
39
- },
40
- [navigate],
41
- );
42
- const onTabChange = useCallback(
43
- (nextTab: WorkflowRunTab) => {
44
- navigate({
45
- search: ((previous: Record<string, unknown>) => {
46
- const current = validateWorkflowRunsSearch(previous);
47
- const nextSearch = workflowRunSearchParams({...current, tab: nextTab}, current);
48
- return nextTab === 'summary' &&
49
- (current.jobId || current.jobExecutionId || current.stepId || current.stepAttemptId)
50
- ? {...nextSearch, tab: nextTab}
51
- : nextSearch;
52
- }) as never,
53
- });
54
- },
55
- [navigate],
56
- );
57
-
58
19
  return (
59
20
  <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
60
21
  <WorkflowRunView
@@ -62,10 +23,9 @@ export function WorkflowRunDetailPage({
62
23
  workspaceSlug={workspaceSlug}
63
24
  projectSlug={projectSlug}
64
25
  workflowRunId={workflowRunId}
65
- selection={selection}
66
- onSelectionChange={onSelectionChange}
26
+ runAttempt={search.runAttempt}
27
+ selection={search}
67
28
  tab={workflowRunTab(search)}
68
- onTabChange={onTabChange}
69
29
  />
70
30
  </div>
71
31
  );