@shipfox/client-workflows 16.0.0 → 18.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 (182) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/dist/components/identifier/identifier.js +2 -2
  4. package/dist/components/identifier/identifier.js.map +1 -1
  5. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -1
  6. package/dist/components/job-detail/agent-config-failure-callout.js +1 -1
  7. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -1
  8. package/dist/components/job-detail/job-context-panel.d.ts +6 -0
  9. package/dist/components/job-detail/job-context-panel.d.ts.map +1 -0
  10. package/dist/components/job-detail/job-context-panel.js +228 -0
  11. package/dist/components/job-detail/job-context-panel.js.map +1 -0
  12. package/dist/components/job-detail/job-detail-header.d.ts +3 -1
  13. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
  14. package/dist/components/job-detail/job-detail-header.js +54 -48
  15. package/dist/components/job-detail/job-detail-header.js.map +1 -1
  16. package/dist/components/job-detail/job-detail-view.d.ts +1 -1
  17. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  18. package/dist/components/job-detail/job-detail-view.js +100 -57
  19. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  20. package/dist/components/job-detail/job-empty-states.d.ts +4 -0
  21. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
  22. package/dist/components/job-detail/job-empty-states.js +60 -11
  23. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  24. package/dist/components/job-detail/job-execution-switcher.js +4 -4
  25. package/dist/components/job-detail/job-execution-switcher.js.map +1 -1
  26. package/dist/components/job-detail/json-code.d.ts +14 -0
  27. package/dist/components/job-detail/json-code.d.ts.map +1 -0
  28. package/dist/components/job-detail/json-code.js +93 -0
  29. package/dist/components/job-detail/json-code.js.map +1 -0
  30. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  31. package/dist/components/job-detail/step-attempt-log-panel.js +9 -6
  32. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  33. package/dist/components/job-detail/step-troubleshooting.d.ts +18 -0
  34. package/dist/components/job-detail/step-troubleshooting.d.ts.map +1 -0
  35. package/dist/components/job-detail/step-troubleshooting.js +497 -0
  36. package/dist/components/job-detail/step-troubleshooting.js.map +1 -0
  37. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
  38. package/dist/components/job-graph/job-graph-content.js +5 -4
  39. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  40. package/dist/components/job-graph/job-node.js +3 -3
  41. package/dist/components/job-graph/job-node.js.map +1 -1
  42. package/dist/components/step-list/index.d.ts +1 -0
  43. package/dist/components/step-list/index.d.ts.map +1 -1
  44. package/dist/components/step-list/index.js.map +1 -1
  45. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  46. package/dist/components/step-list/step-list-model.js +6 -0
  47. package/dist/components/step-list/step-list-model.js.map +1 -1
  48. package/dist/components/step-list/step-list.d.ts +5 -1
  49. package/dist/components/step-list/step-list.d.ts.map +1 -1
  50. package/dist/components/step-list/step-list.js +117 -82
  51. package/dist/components/step-list/step-list.js.map +1 -1
  52. package/dist/components/workflow-run-duration-label.js +1 -1
  53. package/dist/components/workflow-run-duration-label.js.map +1 -1
  54. package/dist/components/workflow-run-list/job-status-strip.js +6 -6
  55. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  56. package/dist/components/workflow-run-list/workflow-run-filter-menu.js +1 -1
  57. package/dist/components/workflow-run-list/workflow-run-filter-menu.js.map +1 -1
  58. package/dist/components/workflow-run-list/workflow-run-filters.js +4 -4
  59. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  60. package/dist/components/workflow-run-list/workflow-run-list-states.js +6 -6
  61. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  62. package/dist/components/workflow-run-list/workflow-run-list-view.js +1 -1
  63. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  64. package/dist/components/workflow-run-list/workflow-run-row.js +7 -7
  65. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  66. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +1 -1
  67. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  68. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +0 -1
  69. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  70. package/dist/components/workflow-run-summary/workflow-run-summary.js +8 -13
  71. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  72. package/dist/components/workflow-run-tabs/index.d.ts +1 -1
  73. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  74. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  75. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +2 -2
  76. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -1
  77. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +9 -2
  78. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -1
  79. package/dist/components/workflow-run-tabs/run-annotation-list.js +28 -19
  80. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -1
  81. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  82. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +5 -5
  83. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  84. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  85. package/dist/components/workflow-run-view/run-workspace-nav.js +11 -10
  86. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  87. package/dist/components/workflow-run-view/workflow-run-states.js +6 -6
  88. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  89. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  90. package/dist/components/workflow-run-view/workflow-run-view.js +72 -12
  91. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  92. package/dist/core/entities/job-execution.d.ts +9 -0
  93. package/dist/core/entities/job-execution.d.ts.map +1 -1
  94. package/dist/core/entities/job-execution.js.map +1 -1
  95. package/dist/core/entities/job.d.ts +10 -1
  96. package/dist/core/entities/job.d.ts.map +1 -1
  97. package/dist/core/entities/job.js.map +1 -1
  98. package/dist/core/entities/step-attempt.d.ts +29 -0
  99. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  100. package/dist/core/entities/step-attempt.js.map +1 -1
  101. package/dist/core/entities/step.d.ts +3 -1
  102. package/dist/core/entities/step.d.ts.map +1 -1
  103. package/dist/core/entities/step.js.map +1 -1
  104. package/dist/core/run-annotation.d.ts +6 -0
  105. package/dist/core/run-annotation.d.ts.map +1 -1
  106. package/dist/core/run-annotation.js.map +1 -1
  107. package/dist/core/workflow-run.d.ts +1 -1
  108. package/dist/core/workflow-run.d.ts.map +1 -1
  109. package/dist/core/workflow-run.js.map +1 -1
  110. package/dist/hooks/api/annotations.d.ts +63 -0
  111. package/dist/hooks/api/annotations.d.ts.map +1 -0
  112. package/dist/hooks/api/annotations.js +145 -0
  113. package/dist/hooks/api/annotations.js.map +1 -0
  114. package/dist/hooks/api/step-attempt-detail.d.ts +14 -0
  115. package/dist/hooks/api/step-attempt-detail.d.ts.map +1 -0
  116. package/dist/hooks/api/step-attempt-detail.js +43 -0
  117. package/dist/hooks/api/step-attempt-detail.js.map +1 -0
  118. package/dist/hooks/api/workflow-run-mapper.d.ts +9 -2
  119. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  120. package/dist/hooks/api/workflow-run-mapper.js +48 -0
  121. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  122. package/dist/pages/project-workflows-page.js +19 -19
  123. package/dist/pages/project-workflows-page.js.map +1 -1
  124. package/dist/pages/workflow-run-list-page.js +1 -1
  125. package/dist/pages/workflow-run-list-page.js.map +1 -1
  126. package/dist/tsconfig.test.tsbuildinfo +1 -1
  127. package/package.json +10 -10
  128. package/src/components/identifier/identifier.tsx +2 -2
  129. package/src/components/job-detail/agent-config-failure-callout.tsx +5 -1
  130. package/src/components/job-detail/job-context-panel.test.tsx +72 -0
  131. package/src/components/job-detail/job-context-panel.tsx +241 -0
  132. package/src/components/job-detail/job-detail-header.tsx +52 -46
  133. package/src/components/job-detail/job-detail-view.stories.tsx +555 -53
  134. package/src/components/job-detail/job-detail-view.tsx +105 -59
  135. package/src/components/job-detail/job-empty-states.test.ts +105 -1
  136. package/src/components/job-detail/job-empty-states.tsx +77 -10
  137. package/src/components/job-detail/job-execution-switcher.tsx +5 -5
  138. package/src/components/job-detail/json-code.tsx +120 -0
  139. package/src/components/job-detail/step-attempt-log-panel.test.tsx +1 -0
  140. package/src/components/job-detail/step-attempt-log-panel.tsx +17 -6
  141. package/src/components/job-detail/step-troubleshooting.stories.tsx +120 -0
  142. package/src/components/job-detail/step-troubleshooting.test.tsx +337 -0
  143. package/src/components/job-detail/step-troubleshooting.tsx +541 -0
  144. package/src/components/job-graph/job-graph-content.tsx +4 -3
  145. package/src/components/job-graph/job-node.tsx +3 -3
  146. package/src/components/step-list/index.ts +1 -0
  147. package/src/components/step-list/step-list-model.ts +6 -0
  148. package/src/components/step-list/step-list.test.tsx +75 -2
  149. package/src/components/step-list/step-list.tsx +73 -20
  150. package/src/components/workflow-run-duration-label.tsx +1 -1
  151. package/src/components/workflow-run-list/job-status-strip.tsx +6 -6
  152. package/src/components/workflow-run-list/workflow-run-filter-menu.tsx +1 -1
  153. package/src/components/workflow-run-list/workflow-run-filters.tsx +4 -4
  154. package/src/components/workflow-run-list/workflow-run-list-states.tsx +6 -6
  155. package/src/components/workflow-run-list/workflow-run-list-view.tsx +1 -1
  156. package/src/components/workflow-run-list/workflow-run-row.tsx +7 -7
  157. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +1 -1
  158. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +1 -1
  159. package/src/components/workflow-run-summary/workflow-run-summary.tsx +11 -11
  160. package/src/components/workflow-run-tabs/index.ts +5 -1
  161. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +2 -2
  162. package/src/components/workflow-run-tabs/run-annotation-list.tsx +25 -10
  163. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +5 -5
  164. package/src/components/workflow-run-view/run-workspace-nav.tsx +18 -11
  165. package/src/components/workflow-run-view/workflow-run-states.tsx +6 -6
  166. package/src/components/workflow-run-view/workflow-run-view.tsx +114 -14
  167. package/src/core/entities/job-execution.ts +9 -0
  168. package/src/core/entities/job.ts +12 -1
  169. package/src/core/entities/step-attempt.ts +33 -0
  170. package/src/core/entities/step.ts +3 -1
  171. package/src/core/run-annotation.ts +6 -0
  172. package/src/core/workflow-run.ts +4 -0
  173. package/src/hooks/api/annotations.test.ts +84 -0
  174. package/src/hooks/api/annotations.ts +240 -0
  175. package/src/hooks/api/step-attempt-detail.ts +67 -0
  176. package/src/hooks/api/workflow-run-mapper.ts +47 -0
  177. package/src/hooks/api/workflow-runs.test.tsx +61 -1
  178. package/src/pages/project-workflows-page.tsx +19 -19
  179. package/src/pages/workflow-job-detail-page.test.tsx +52 -1
  180. package/src/pages/workflow-run-list-page.tsx +1 -1
  181. package/test/fixtures/workflow-run.ts +7 -0
  182. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,120 @@
1
+ import {
2
+ CodeBlock,
3
+ CodeBlockBody,
4
+ CodeBlockContent,
5
+ CodeBlockCopyButton,
6
+ CodeBlockFilename,
7
+ CodeBlockFiles,
8
+ CodeBlockHeader,
9
+ CodeBlockItem,
10
+ CodeTabs,
11
+ } from '@shipfox/react-ui/code-block';
12
+ import {Text} from '@shipfox/react-ui/typography';
13
+ import {useState} from 'react';
14
+
15
+ export interface JsonCodeEntry {
16
+ filename: string;
17
+ label?: string | undefined;
18
+ value: unknown;
19
+ }
20
+
21
+ export function JsonCode({
22
+ title,
23
+ value,
24
+ emptyMessage,
25
+ }: {
26
+ title?: string | undefined;
27
+ value: unknown;
28
+ emptyMessage?: string | undefined;
29
+ }) {
30
+ const filename = title ?? 'value.json';
31
+
32
+ return (
33
+ <div className="flex min-w-0 flex-col gap-tight">
34
+ {title ? (
35
+ <Text size="xs" className="text-foreground-neutral-muted">
36
+ {title}
37
+ </Text>
38
+ ) : null}
39
+ {emptyMessage && isEmptyObject(value) ? (
40
+ <Text size="xs" className="text-foreground-neutral-muted">
41
+ {emptyMessage}
42
+ </Text>
43
+ ) : (
44
+ <JsonCodeBlock entries={[{filename, value}]} />
45
+ )}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function JsonCodeTabs({entries}: {entries: readonly JsonCodeEntry[]}) {
51
+ const entryKeys = entries.map((entry) => entry.label ?? entry.filename);
52
+ const [activeKey, setActiveKey] = useState(entryKeys[0] ?? '');
53
+ const activeValue = entryKeys.includes(activeKey) ? activeKey : (entryKeys[0] ?? '');
54
+ const codes = Object.fromEntries(
55
+ entries.map((entry) => [entry.label ?? entry.filename, stringifyJson(entry.value)]),
56
+ );
57
+
58
+ return (
59
+ <CodeTabs
60
+ value={activeValue}
61
+ onValueChange={setActiveKey}
62
+ codes={codes}
63
+ lang="json"
64
+ syntaxHighlighting={false}
65
+ className="h-auto min-h-0 rounded-8 bg-background-contrast-base shadow-button-neutral"
66
+ />
67
+ );
68
+ }
69
+
70
+ function JsonCodeBlock({entries}: {entries: readonly JsonCodeEntry[]}) {
71
+ const data = entries.map((entry) => ({
72
+ filename: entry.filename,
73
+ language: 'json',
74
+ code: stringifyJson(entry.value),
75
+ }));
76
+
77
+ return (
78
+ <CodeBlock
79
+ data={data}
80
+ className="h-auto min-h-0 rounded-6 bg-background-contrast-base shadow-button-neutral"
81
+ >
82
+ <CodeBlockHeader className="bg-background-contrast-base p-tight">
83
+ <CodeBlockFiles>
84
+ {(item) => {
85
+ const entry = entries.find((candidate) => candidate.filename === item.filename);
86
+ return (
87
+ <CodeBlockFilename value={item.filename}>
88
+ {entry?.label ?? item.filename}
89
+ </CodeBlockFilename>
90
+ );
91
+ }}
92
+ </CodeBlockFiles>
93
+ <CodeBlockCopyButton />
94
+ </CodeBlockHeader>
95
+ <CodeBlockBody>
96
+ {(item) => (
97
+ <CodeBlockItem value={item.filename}>
98
+ <CodeBlockContent language="json" syntaxHighlighting={false}>
99
+ {item.code}
100
+ </CodeBlockContent>
101
+ </CodeBlockItem>
102
+ )}
103
+ </CodeBlockBody>
104
+ </CodeBlock>
105
+ );
106
+ }
107
+
108
+ function isEmptyObject(value: unknown): boolean {
109
+ return (
110
+ typeof value === 'object' &&
111
+ value !== null &&
112
+ !Array.isArray(value) &&
113
+ Object.keys(value).length === 0
114
+ );
115
+ }
116
+
117
+ function stringifyJson(value: unknown): string {
118
+ const serialized = JSON.stringify(value, null, 2);
119
+ return serialized === undefined ? String(value) : serialized;
120
+ }
@@ -203,6 +203,7 @@ describe('StepAttemptLogPanel', () => {
203
203
  renderPanel({attemptStatus: 'succeeded'});
204
204
 
205
205
  expect(await screen.findByText('hello')).toBeInTheDocument();
206
+ expect(screen.getByRole('log')).toHaveAttribute('aria-live', 'off');
206
207
  });
207
208
 
208
209
  test('renders a terminal closed empty stream as no output', async () => {
@@ -100,10 +100,15 @@ export function StepAttemptLogPanel({
100
100
  }
101
101
 
102
102
  return (
103
- <div className="flex min-w-0 flex-col gap-8">
103
+ <div className="flex min-w-0 flex-col gap-inline">
104
104
  {staleError ? (
105
- <Callout role="alert" type="warning" className="px-10 py-8">
106
- <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
105
+ <Callout
106
+ role="alert"
107
+ type="warning"
108
+ variant="secondary"
109
+ className="rounded-none border-b border-border-neutral-base px-0 py-row shadow-none"
110
+ >
111
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
107
112
  <Text size="xs">Could not refresh logs.</Text>
108
113
  <Button
109
114
  type="button"
@@ -121,6 +126,7 @@ export function StepAttemptLogPanel({
121
126
  records={records}
122
127
  emptyState={query.data?.complete ? 'complete' : 'pending'}
123
128
  anchorToFailure={anchorToFailure}
129
+ ariaLive={attemptStatus === 'running' ? 'polite' : 'off'}
124
130
  className={surfaceClassName}
125
131
  />
126
132
  </div>
@@ -138,7 +144,7 @@ function StepLogsLoadingSurface({label, className}: {label: string; className: s
138
144
  function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
139
145
  return (
140
146
  <div role="status" aria-label="Waiting for logs" className={className}>
141
- <Text size="xs" className="px-10 py-12 text-foreground-neutral-muted">
147
+ <Text size="xs" className="px-tight py-row text-foreground-neutral-muted">
142
148
  Waiting for output ·{' '}
143
149
  <span className="font-code tabular-nums" aria-hidden="true">
144
150
  <JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
@@ -174,8 +180,13 @@ function isTerminalAttemptStatus(status: string): boolean {
174
180
 
175
181
  function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => void}) {
176
182
  return (
177
- <Callout role="alert" type="error" className="px-10 py-8">
178
- <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
183
+ <Callout
184
+ role="alert"
185
+ type="error"
186
+ variant="secondary"
187
+ className="rounded-none border-b border-border-neutral-base px-0 py-row shadow-none"
188
+ >
189
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
179
190
  <Text size="xs">Could not load logs.</Text>
180
191
  <Button type="button" size="2xs" variant="secondary" isLoading={retrying} onClick={onRetry}>
181
192
  Retry
@@ -0,0 +1,120 @@
1
+ import type {Meta, StoryObj} from '@storybook/react';
2
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
3
+ import {useState} from 'react';
4
+ import {
5
+ workflowJob,
6
+ workflowJobExecutionDto,
7
+ workflowStepAttemptDto,
8
+ workflowStepDto,
9
+ } from '#test/fixtures/workflow-run.js';
10
+ import {buildStepListModel, type StepListEntryModel} from '../step-list/step-list-model.js';
11
+ import {StepInspectorSheet} from './step-troubleshooting.js';
12
+
13
+ const meta = {
14
+ title: 'Workflows/StepInspector',
15
+ parameters: {
16
+ layout: 'fullscreen',
17
+ },
18
+ } satisfies Meta;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const FailedStep: Story = {
24
+ render: () => <FailedStepStory />,
25
+ };
26
+
27
+ function FailedStepStory() {
28
+ const [queryClient] = useState(
29
+ () => new QueryClient({defaultOptions: {queries: {staleTime: Number.POSITIVE_INFINITY}}}),
30
+ );
31
+ const entry = failedStepEntry();
32
+
33
+ return (
34
+ <QueryClientProvider client={queryClient}>
35
+ <main className="min-h-screen bg-background-neutral-base p-16">
36
+ <StepInspectorSheet
37
+ entry={entry}
38
+ open
39
+ onOpenChange={() => undefined}
40
+ workspaceSlug="acme"
41
+ projectSlug="platform"
42
+ workflowRunId="11111111-1111-4111-8111-111111111111"
43
+ runAttempt={1}
44
+ jobId="44444444-4444-4444-8444-000000000001"
45
+ />
46
+ </main>
47
+ </QueryClientProvider>
48
+ );
49
+ }
50
+
51
+ function failedStepEntry(): StepListEntryModel {
52
+ const jobId = '44444444-4444-4444-8444-000000000001';
53
+ const executionId = '77777777-7777-4777-8777-000000000001';
54
+ const stepId = '55555555-5555-4555-8555-000000000001';
55
+ const attemptId = '66666666-6666-4666-8666-000000000001';
56
+ const job = workflowJob({
57
+ id: jobId,
58
+ name: 'verification',
59
+ key: 'verification',
60
+ status: 'failed',
61
+ job_executions: [
62
+ workflowJobExecutionDto({
63
+ id: executionId,
64
+ job_id: jobId,
65
+ status: 'failed',
66
+ steps: [
67
+ workflowStepDto({
68
+ id: stepId,
69
+ job_execution_id: executionId,
70
+ name: 'Run verification suite',
71
+ key: 'run-verification',
72
+ status: 'failed',
73
+ status_reason: 'agent_invocation_failed',
74
+ config: {run: 'pnpm test --filter=@shipfox/client-workflows'},
75
+ evaluation_trace: [
76
+ {
77
+ expression: 'inputs["branch"]',
78
+ roots: ['inputs'],
79
+ fill_target: 'step-dispatch',
80
+ evaluated_at: '2026-06-21T12:04:00.000Z',
81
+ field: 'branch',
82
+ value: 'main',
83
+ },
84
+ ],
85
+ error: {
86
+ message: 'The verification agent returned a non-zero exit code.',
87
+ reason: 'agent_invocation_failed',
88
+ category: 'user',
89
+ exit_code: 1,
90
+ },
91
+ attempts: [
92
+ workflowStepAttemptDto({
93
+ id: attemptId,
94
+ step_id: stepId,
95
+ status: 'failed',
96
+ exit_code: 1,
97
+ output: {summary: 'Tests failed before the report was uploaded.'},
98
+ outputs: {failed_tests: 3},
99
+ error: {
100
+ message: 'The verification agent returned a non-zero exit code.',
101
+ reason: 'agent_invocation_failed',
102
+ category: 'user',
103
+ exit_code: 1,
104
+ },
105
+ finished_at: '2026-06-21T12:04:00.000Z',
106
+ }),
107
+ ],
108
+ }),
109
+ ],
110
+ }),
111
+ ],
112
+ });
113
+ const execution = job.jobExecutions[0];
114
+ if (!execution) throw new Error('Story fixture is missing a job execution.');
115
+
116
+ const entry = buildStepListModel({job, jobExecution: execution}).entries[0];
117
+ if (!entry) throw new Error('Story fixture is missing a step attempt.');
118
+
119
+ return entry;
120
+ }
@@ -0,0 +1,337 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
3
+ import {
4
+ createMemoryHistory,
5
+ createRootRoute,
6
+ createRoute,
7
+ createRouter,
8
+ Outlet,
9
+ RouterProvider,
10
+ } from '@tanstack/react-router';
11
+ import {act, render, screen} from '@testing-library/react';
12
+ import userEvent from '@testing-library/user-event';
13
+ import {useState} from 'react';
14
+ import {
15
+ workflowJob,
16
+ workflowJobExecutionDto,
17
+ workflowStepAttemptDto,
18
+ workflowStepDto,
19
+ } from '#test/fixtures/workflow-run.js';
20
+ import {buildStepListModel, type StepListEntryModel} from '../step-list/step-list-model.js';
21
+ import {StepInspectorSheet} from './step-troubleshooting.js';
22
+
23
+ const STEP_ID = '55555555-5555-4555-8555-555555555555';
24
+ const ATTEMPT_ID = '66666666-6666-4666-8666-666666666666';
25
+ const EXECUTION_ID = '77777777-7777-4777-8777-777777777777';
26
+ const INSPECTOR_TRIGGER_NAME = 'Open inspector';
27
+
28
+ describe('StepInspectorSheet', () => {
29
+ afterEach(() => {
30
+ configureApiClient({baseUrl: '', fetchImpl: undefined});
31
+ });
32
+
33
+ it('shows a loading state only after the inspector is opened', async () => {
34
+ const user = userEvent.setup();
35
+ configureApiClient({
36
+ fetchImpl: vi.fn(() => new Promise<Response>(() => undefined)),
37
+ });
38
+
39
+ await renderPanel();
40
+
41
+ expect(screen.queryByRole('status', {name: 'Loading troubleshooting details'})).toBeNull();
42
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
43
+ expect(
44
+ await screen.findByRole('status', {name: 'Loading troubleshooting details'}),
45
+ ).toBeInTheDocument();
46
+ });
47
+
48
+ it('keeps failure detail out of the default log path', async () => {
49
+ const user = userEvent.setup();
50
+ configureApiClient({
51
+ fetchImpl: vi.fn(() => new Promise<Response>(() => undefined)),
52
+ });
53
+
54
+ await renderPanel();
55
+
56
+ expect(screen.queryByRole('alert')).toBeNull();
57
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
58
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
59
+ });
60
+
61
+ it('shows the evaluation count only after the lazy detail response arrives', async () => {
62
+ const user = userEvent.setup();
63
+ configureApiClient({
64
+ fetchImpl: vi.fn(async () =>
65
+ jsonResponse({
66
+ step_id: STEP_ID,
67
+ attempt: 1,
68
+ authored_config: {run: 'pnpm test'},
69
+ config: {run: 'pnpm test --filter=client'},
70
+ evaluation_trace: [
71
+ {
72
+ expression: 'inputs.message',
73
+ roots: ['inputs.message'],
74
+ fill_target: 'run',
75
+ evaluated_at: '2026-08-05T12:00:00.000Z',
76
+ field: 'run',
77
+ value: 'hello',
78
+ },
79
+ ],
80
+ }),
81
+ ),
82
+ });
83
+
84
+ await renderPanel();
85
+
86
+ expect(screen.queryByText('Evaluation')).toBeNull();
87
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
88
+ expect((await screen.findAllByText('Evaluation')).length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it('shows an actionable error and retries the detail request', async () => {
92
+ const user = userEvent.setup();
93
+ const fetchImpl = vi
94
+ .fn()
95
+ .mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
96
+ .mockResolvedValueOnce(
97
+ jsonResponse({
98
+ step_id: STEP_ID,
99
+ attempt: 1,
100
+ authored_config: {run: 'pnpm test'},
101
+ config: {run: 'pnpm test --filter=client'},
102
+ evaluation_trace: null,
103
+ }),
104
+ );
105
+ configureApiClient({fetchImpl});
106
+
107
+ await renderPanel();
108
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
109
+
110
+ expect(await screen.findByText('Details unavailable')).toBeInTheDocument();
111
+ await user.click(screen.getByRole('button', {name: 'Retry'}));
112
+ expect(await screen.findByRole('region', {name: 'Inputs'})).toBeInTheDocument();
113
+ expect(screen.getByText('Resolved configuration')).toBeInTheDocument();
114
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
115
+ });
116
+
117
+ it('keeps the annotation link available when the detail request fails', async () => {
118
+ const user = userEvent.setup();
119
+ configureApiClient({
120
+ fetchImpl: vi.fn(() => Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}))),
121
+ });
122
+
123
+ await renderPanel({annotationCount: 2});
124
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
125
+
126
+ expect(await screen.findByText('Details unavailable')).toBeInTheDocument();
127
+ expect(screen.getByRole('link', {name: 'View 2 annotations'})).toBeInTheDocument();
128
+ });
129
+
130
+ it('does not replace an annotation link with an empty inspector state', async () => {
131
+ const user = userEvent.setup();
132
+ configureApiClient({
133
+ fetchImpl: vi.fn(async () =>
134
+ jsonResponse({
135
+ step_id: STEP_ID,
136
+ attempt: 1,
137
+ authored_config: null,
138
+ config: null,
139
+ evaluation_trace: null,
140
+ }),
141
+ ),
142
+ });
143
+
144
+ await renderPanel({annotationCount: 2, entry: emptyStepEntry()});
145
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
146
+
147
+ expect(await screen.findByRole('link', {name: 'View 2 annotations'})).toBeInTheDocument();
148
+ expect(screen.queryByText('No additional troubleshooting details were recorded.')).toBeNull();
149
+ });
150
+
151
+ it('does not show current step configuration or evaluation for a historical attempt', async () => {
152
+ const user = userEvent.setup();
153
+ configureApiClient({
154
+ fetchImpl: vi.fn(async () =>
155
+ jsonResponse({
156
+ step_id: STEP_ID,
157
+ attempt: 1,
158
+ authored_config: null,
159
+ config: null,
160
+ evaluation_trace: null,
161
+ }),
162
+ ),
163
+ });
164
+
165
+ await renderPanel();
166
+ await user.click(screen.getByRole('button', {name: INSPECTOR_TRIGGER_NAME}));
167
+ expect(await screen.findByRole('region', {name: 'Outputs'})).toBeInTheDocument();
168
+
169
+ expect(screen.queryByText('Authored configuration')).toBeNull();
170
+ expect(screen.queryByText('Resolved configuration')).toBeNull();
171
+ expect(screen.queryByText('Evaluation')).toBeNull();
172
+ });
173
+ });
174
+
175
+ async function renderPanel({
176
+ annotationCount,
177
+ entry,
178
+ }: {
179
+ annotationCount?: number;
180
+ entry?: StepListEntryModel;
181
+ } = {}) {
182
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
183
+ const rootRoute = createRootRoute({component: Outlet});
184
+ const panelRoute = createRoute({
185
+ getParentRoute: () => rootRoute,
186
+ path: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
187
+ component: () => (
188
+ <QueryClientProvider client={queryClient}>
189
+ <PanelHarness annotationCount={annotationCount} entry={entry} />
190
+ </QueryClientProvider>
191
+ ),
192
+ });
193
+ const router = createRouter({
194
+ history: createMemoryHistory({initialEntries: ['/w/acme/p/platform/runs/run-1']}),
195
+ routeTree: rootRoute.addChildren([panelRoute]),
196
+ });
197
+ await router.load();
198
+ let result: ReturnType<typeof render> | undefined;
199
+ await act(() => {
200
+ result = render(<RouterProvider router={router} />);
201
+ });
202
+ if (!result) throw new Error('Step inspector did not render.');
203
+ return result;
204
+ }
205
+
206
+ function PanelHarness({
207
+ annotationCount,
208
+ entry: providedEntry,
209
+ }: {
210
+ annotationCount?: number | undefined;
211
+ entry?: StepListEntryModel | undefined;
212
+ }) {
213
+ const [inspectorOpen, setInspectorOpen] = useState(false);
214
+ const entry = providedEntry ?? stepEntry();
215
+ return (
216
+ <>
217
+ <button type="button" onClick={() => setInspectorOpen(true)}>
218
+ Open inspector
219
+ </button>
220
+ <StepInspectorSheet
221
+ entry={entry}
222
+ open={inspectorOpen}
223
+ onOpenChange={setInspectorOpen}
224
+ workspaceSlug="acme"
225
+ projectSlug="platform"
226
+ workflowRunId="11111111-1111-4111-8111-111111111111"
227
+ runAttempt={1}
228
+ jobId="44444444-4444-4444-8444-444444444444"
229
+ annotationCount={annotationCount}
230
+ />
231
+ </>
232
+ );
233
+ }
234
+
235
+ function stepEntry(): StepListEntryModel {
236
+ const jobId = '44444444-4444-4444-8444-444444444444';
237
+ const job = workflowJob({
238
+ id: jobId,
239
+ name: 'verification',
240
+ key: 'verification',
241
+ status: 'failed',
242
+ job_executions: [
243
+ workflowJobExecutionDto({
244
+ id: EXECUTION_ID,
245
+ job_id: jobId,
246
+ status: 'failed',
247
+ steps: [
248
+ workflowStepDto({
249
+ id: STEP_ID,
250
+ job_execution_id: EXECUTION_ID,
251
+ name: 'Run verification',
252
+ status: 'failed',
253
+ config: {run: 'pnpm test'},
254
+ evaluation_trace: [
255
+ {
256
+ expression: 'inputs.message',
257
+ roots: ['inputs.message'],
258
+ fill_target: 'run',
259
+ evaluated_at: '2026-08-05T12:00:00.000Z',
260
+ field: 'run',
261
+ value: 'current attempt value',
262
+ },
263
+ ],
264
+ attempts: [
265
+ workflowStepAttemptDto({
266
+ id: ATTEMPT_ID,
267
+ step_id: STEP_ID,
268
+ status: 'failed',
269
+ output: {result: 'failed'},
270
+ finished_at: '2026-08-05T12:01:00.000Z',
271
+ }),
272
+ ],
273
+ }),
274
+ ],
275
+ }),
276
+ ],
277
+ });
278
+ const execution = job.jobExecutions[0];
279
+ if (!execution) throw new Error('Test fixture is missing an execution.');
280
+ const entry = buildStepListModel({job, jobExecution: execution}).entries[0];
281
+ if (!entry) throw new Error('Test fixture is missing a step attempt.');
282
+
283
+ return entry;
284
+ }
285
+
286
+ function emptyStepEntry(): StepListEntryModel {
287
+ const jobId = '44444444-4444-4444-8444-444444444444';
288
+ const job = workflowJob({
289
+ id: jobId,
290
+ name: 'verification',
291
+ key: 'verification',
292
+ status: 'succeeded',
293
+ job_executions: [
294
+ workflowJobExecutionDto({
295
+ id: EXECUTION_ID,
296
+ job_id: jobId,
297
+ status: 'succeeded',
298
+ steps: [
299
+ workflowStepDto({
300
+ id: STEP_ID,
301
+ job_execution_id: EXECUTION_ID,
302
+ name: 'Run verification',
303
+ status: 'succeeded',
304
+ config: {},
305
+ attempts: [
306
+ workflowStepAttemptDto({
307
+ id: ATTEMPT_ID,
308
+ step_id: STEP_ID,
309
+ status: 'succeeded',
310
+ output: null,
311
+ outputs: null,
312
+ response: null,
313
+ error: null,
314
+ exit_code: 0,
315
+ finished_at: '2026-08-05T12:01:00.000Z',
316
+ }),
317
+ ],
318
+ }),
319
+ ],
320
+ }),
321
+ ],
322
+ });
323
+ const execution = job.jobExecutions[0];
324
+ if (!execution) throw new Error('Test fixture is missing an execution.');
325
+ const entry = buildStepListModel({job, jobExecution: execution}).entries[0];
326
+ if (!entry) throw new Error('Test fixture is missing a step attempt.');
327
+
328
+ return entry;
329
+ }
330
+
331
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
332
+ return new Response(JSON.stringify(body), {
333
+ status: 200,
334
+ headers: {'content-type': 'application/json'},
335
+ ...init,
336
+ });
337
+ }