@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/components/identifier/identifier.js +2 -2
- package/dist/components/identifier/identifier.js.map +1 -1
- package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -1
- package/dist/components/job-detail/agent-config-failure-callout.js +1 -1
- package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -1
- package/dist/components/job-detail/job-context-panel.d.ts +6 -0
- package/dist/components/job-detail/job-context-panel.d.ts.map +1 -0
- package/dist/components/job-detail/job-context-panel.js +228 -0
- package/dist/components/job-detail/job-context-panel.js.map +1 -0
- package/dist/components/job-detail/job-detail-header.d.ts +3 -1
- package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-header.js +54 -48
- package/dist/components/job-detail/job-detail-header.js.map +1 -1
- package/dist/components/job-detail/job-detail-view.d.ts +1 -1
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-view.js +100 -57
- package/dist/components/job-detail/job-detail-view.js.map +1 -1
- package/dist/components/job-detail/job-empty-states.d.ts +4 -0
- package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
- package/dist/components/job-detail/job-empty-states.js +60 -11
- package/dist/components/job-detail/job-empty-states.js.map +1 -1
- package/dist/components/job-detail/job-execution-switcher.js +4 -4
- package/dist/components/job-detail/job-execution-switcher.js.map +1 -1
- package/dist/components/job-detail/json-code.d.ts +14 -0
- package/dist/components/job-detail/json-code.d.ts.map +1 -0
- package/dist/components/job-detail/json-code.js +93 -0
- package/dist/components/job-detail/json-code.js.map +1 -0
- package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
- package/dist/components/job-detail/step-attempt-log-panel.js +9 -6
- package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
- package/dist/components/job-detail/step-troubleshooting.d.ts +18 -0
- package/dist/components/job-detail/step-troubleshooting.d.ts.map +1 -0
- package/dist/components/job-detail/step-troubleshooting.js +497 -0
- package/dist/components/job-detail/step-troubleshooting.js.map +1 -0
- package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
- package/dist/components/job-graph/job-graph-content.js +5 -4
- package/dist/components/job-graph/job-graph-content.js.map +1 -1
- package/dist/components/job-graph/job-node.js +3 -3
- package/dist/components/job-graph/job-node.js.map +1 -1
- package/dist/components/step-list/index.d.ts +1 -0
- package/dist/components/step-list/index.d.ts.map +1 -1
- package/dist/components/step-list/index.js.map +1 -1
- package/dist/components/step-list/step-list-model.d.ts.map +1 -1
- package/dist/components/step-list/step-list-model.js +6 -0
- package/dist/components/step-list/step-list-model.js.map +1 -1
- package/dist/components/step-list/step-list.d.ts +5 -1
- package/dist/components/step-list/step-list.d.ts.map +1 -1
- package/dist/components/step-list/step-list.js +117 -82
- package/dist/components/step-list/step-list.js.map +1 -1
- package/dist/components/workflow-run-duration-label.js +1 -1
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.js +6 -6
- package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-filter-menu.js +1 -1
- package/dist/components/workflow-run-list/workflow-run-filter-menu.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-filters.js +4 -4
- package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-states.js +6 -6
- package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.js +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +7 -7
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +1 -1
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +0 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +8 -13
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-tabs/index.d.ts +1 -1
- package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/index.js.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +2 -2
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +9 -2
- package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-list.js +28 -19
- package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +5 -5
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js +11 -10
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.js +6 -6
- package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +72 -12
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/core/entities/job-execution.d.ts +9 -0
- package/dist/core/entities/job-execution.d.ts.map +1 -1
- package/dist/core/entities/job-execution.js.map +1 -1
- package/dist/core/entities/job.d.ts +10 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/entities/step-attempt.d.ts +29 -0
- package/dist/core/entities/step-attempt.d.ts.map +1 -1
- package/dist/core/entities/step-attempt.js.map +1 -1
- package/dist/core/entities/step.d.ts +3 -1
- package/dist/core/entities/step.d.ts.map +1 -1
- package/dist/core/entities/step.js.map +1 -1
- package/dist/core/run-annotation.d.ts +6 -0
- package/dist/core/run-annotation.d.ts.map +1 -1
- package/dist/core/run-annotation.js.map +1 -1
- package/dist/core/workflow-run.d.ts +1 -1
- package/dist/core/workflow-run.d.ts.map +1 -1
- package/dist/core/workflow-run.js.map +1 -1
- package/dist/hooks/api/annotations.d.ts +63 -0
- package/dist/hooks/api/annotations.d.ts.map +1 -0
- package/dist/hooks/api/annotations.js +145 -0
- package/dist/hooks/api/annotations.js.map +1 -0
- package/dist/hooks/api/step-attempt-detail.d.ts +14 -0
- package/dist/hooks/api/step-attempt-detail.d.ts.map +1 -0
- package/dist/hooks/api/step-attempt-detail.js +43 -0
- package/dist/hooks/api/step-attempt-detail.js.map +1 -0
- package/dist/hooks/api/workflow-run-mapper.d.ts +9 -2
- package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.js +48 -0
- package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
- package/dist/pages/project-workflows-page.js +19 -19
- package/dist/pages/project-workflows-page.js.map +1 -1
- package/dist/pages/workflow-run-list-page.js +1 -1
- package/dist/pages/workflow-run-list-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/components/identifier/identifier.tsx +2 -2
- package/src/components/job-detail/agent-config-failure-callout.tsx +5 -1
- package/src/components/job-detail/job-context-panel.test.tsx +72 -0
- package/src/components/job-detail/job-context-panel.tsx +241 -0
- package/src/components/job-detail/job-detail-header.tsx +52 -46
- package/src/components/job-detail/job-detail-view.stories.tsx +555 -53
- package/src/components/job-detail/job-detail-view.tsx +105 -59
- package/src/components/job-detail/job-empty-states.test.ts +105 -1
- package/src/components/job-detail/job-empty-states.tsx +77 -10
- package/src/components/job-detail/job-execution-switcher.tsx +5 -5
- package/src/components/job-detail/json-code.tsx +120 -0
- package/src/components/job-detail/step-attempt-log-panel.test.tsx +1 -0
- package/src/components/job-detail/step-attempt-log-panel.tsx +17 -6
- package/src/components/job-detail/step-troubleshooting.stories.tsx +120 -0
- package/src/components/job-detail/step-troubleshooting.test.tsx +337 -0
- package/src/components/job-detail/step-troubleshooting.tsx +541 -0
- package/src/components/job-graph/job-graph-content.tsx +4 -3
- package/src/components/job-graph/job-node.tsx +3 -3
- package/src/components/step-list/index.ts +1 -0
- package/src/components/step-list/step-list-model.ts +6 -0
- package/src/components/step-list/step-list.test.tsx +75 -2
- package/src/components/step-list/step-list.tsx +73 -20
- package/src/components/workflow-run-duration-label.tsx +1 -1
- package/src/components/workflow-run-list/job-status-strip.tsx +6 -6
- package/src/components/workflow-run-list/workflow-run-filter-menu.tsx +1 -1
- package/src/components/workflow-run-list/workflow-run-filters.tsx +4 -4
- package/src/components/workflow-run-list/workflow-run-list-states.tsx +6 -6
- package/src/components/workflow-run-list/workflow-run-list-view.tsx +1 -1
- package/src/components/workflow-run-list/workflow-run-row.tsx +7 -7
- package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +1 -1
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +1 -1
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +11 -11
- package/src/components/workflow-run-tabs/index.ts +5 -1
- package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +2 -2
- package/src/components/workflow-run-tabs/run-annotation-list.tsx +25 -10
- package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +5 -5
- package/src/components/workflow-run-view/run-workspace-nav.tsx +18 -11
- package/src/components/workflow-run-view/workflow-run-states.tsx +6 -6
- package/src/components/workflow-run-view/workflow-run-view.tsx +114 -14
- package/src/core/entities/job-execution.ts +9 -0
- package/src/core/entities/job.ts +12 -1
- package/src/core/entities/step-attempt.ts +33 -0
- package/src/core/entities/step.ts +3 -1
- package/src/core/run-annotation.ts +6 -0
- package/src/core/workflow-run.ts +4 -0
- package/src/hooks/api/annotations.test.ts +84 -0
- package/src/hooks/api/annotations.ts +240 -0
- package/src/hooks/api/step-attempt-detail.ts +67 -0
- package/src/hooks/api/workflow-run-mapper.ts +47 -0
- package/src/hooks/api/workflow-runs.test.tsx +61 -1
- package/src/pages/project-workflows-page.tsx +19 -19
- package/src/pages/workflow-job-detail-page.test.tsx +52 -1
- package/src/pages/workflow-run-list-page.tsx +1 -1
- package/test/fixtures/workflow-run.ts +7 -0
- 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-
|
|
103
|
+
<div className="flex min-w-0 flex-col gap-inline">
|
|
104
104
|
{staleError ? (
|
|
105
|
-
<Callout
|
|
106
|
-
|
|
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-
|
|
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
|
|
178
|
-
|
|
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
|
+
}
|