@shipfox/client-workflows 19.0.0 → 20.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 +14 -0
- package/dist/components/workflow-run-duration-label.d.ts +8 -7
- package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
- package/dist/components/workflow-run-duration-label.js +15 -31
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +9 -6
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -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 +7 -34
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
- package/dist/components/workflow-status/status-visuals.js +0 -9
- package/dist/components/workflow-status/status-visuals.js.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.js +0 -11
- package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +10 -55
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js +1 -94
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/core/workflow-run.d.ts +2 -2
- package/dist/core/workflow-run.d.ts.map +1 -1
- package/dist/core/workflow-run.js +1 -1
- package/dist/core/workflow-run.js.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.js +3 -1
- package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
- package/dist/hooks/api/workflow-runs.js +1 -0
- package/dist/hooks/api/workflow-runs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/workflow-run-duration-label.tsx +21 -44
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +48 -0
- package/src/components/workflow-run-list/workflow-run-row.tsx +7 -6
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +0 -38
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +73 -21
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +6 -27
- package/src/components/workflow-status/status-visuals.ts +0 -4
- package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
- package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
- package/src/core/entities/workflow-run.ts +9 -136
- package/src/core/workflow-run.test.ts +2 -0
- package/src/core/workflow-run.ts +0 -8
- package/src/hooks/api/workflow-run-mapper.ts +2 -0
- package/src/hooks/api/workflow-runs.ts +1 -1
- package/test/fixtures/workflow-run.ts +30 -3
- package/tsconfig.build.tsbuildinfo +1 -1
- package/src/core/entities/workflow-run-display.test.ts +0 -187
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-workflows",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "20.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"date-fns": "^4.1.0",
|
|
30
30
|
"@shipfox/annotations-dto": "12.3.0",
|
|
31
31
|
"@shipfox/api-definitions-dto": "12.3.0",
|
|
32
|
-
"@shipfox/api-workflows-dto": "12.
|
|
32
|
+
"@shipfox/api-workflows-dto": "12.7.0",
|
|
33
33
|
"@shipfox/client-api": "6.0.1",
|
|
34
34
|
"@shipfox/client-logs": "17.0.0",
|
|
35
35
|
"@shipfox/client-projects": "17.0.0",
|
|
@@ -2,50 +2,37 @@ import {Icon} from '@shipfox/react-ui/icon';
|
|
|
2
2
|
import {useTimeTick} from '@shipfox/react-ui/time-ticker';
|
|
3
3
|
import {Code} from '@shipfox/react-ui/typography';
|
|
4
4
|
import {cn, humanDuration} from '@shipfox/react-ui/utils';
|
|
5
|
-
import type {
|
|
6
|
-
WorkflowRunAttemptDisplayDuration,
|
|
7
|
-
WorkflowRunDisplayDuration,
|
|
8
|
-
} from '#core/workflow-run.js';
|
|
5
|
+
import type {WorkflowRunAttemptDisplayDuration} from '#core/workflow-run.js';
|
|
9
6
|
|
|
10
7
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* A run reports one span: how long its attempt has been under way. Queueing is a job
|
|
9
|
+
* execution's own affair, and the job surfaces report it there.
|
|
13
10
|
*/
|
|
14
|
-
export type WorkflowRunDurationDisplay =
|
|
15
|
-
| WorkflowRunAttemptDisplayDuration
|
|
16
|
-
| WorkflowRunDisplayDuration;
|
|
17
|
-
|
|
18
|
-
type DurationKind = 'queue' | 'run';
|
|
19
|
-
|
|
20
|
-
const GLYPH_BY_KIND = {queue: 'hourglassLine', run: 'timerLine'} as const;
|
|
21
|
-
const LIVE_VERB_BY_KIND = {queue: 'queued', run: 'running'} as const;
|
|
22
|
-
const FIXED_VERB_BY_KIND = {queue: 'queued', run: 'ran'} as const;
|
|
11
|
+
export type WorkflowRunDurationDisplay = WorkflowRunAttemptDisplayDuration;
|
|
23
12
|
|
|
24
13
|
export function WorkflowRunDurationLabel({
|
|
25
14
|
duration,
|
|
15
|
+
hasStarted,
|
|
26
16
|
className,
|
|
27
17
|
}: {
|
|
28
18
|
duration: WorkflowRunDurationDisplay | null;
|
|
19
|
+
hasStarted: boolean;
|
|
29
20
|
className?: string | undefined;
|
|
30
21
|
}) {
|
|
31
22
|
if (duration === null) return null;
|
|
32
|
-
const kind = durationKind(duration);
|
|
33
23
|
|
|
34
24
|
switch (duration.state) {
|
|
35
25
|
case 'fixed': {
|
|
36
26
|
const display = formatFixedDurationLabel(duration.elapsed);
|
|
27
|
+
const verb = hasStarted ? 'ran' : 'lasted';
|
|
37
28
|
return (
|
|
38
|
-
<DurationText
|
|
39
|
-
className={className}
|
|
40
|
-
kind={kind}
|
|
41
|
-
ariaLabel={`${FIXED_VERB_BY_KIND[kind]} ${display}`}
|
|
42
|
-
>
|
|
29
|
+
<DurationText className={className} ariaLabel={`${verb} ${display}`}>
|
|
43
30
|
{display}
|
|
44
31
|
</DurationText>
|
|
45
32
|
);
|
|
46
33
|
}
|
|
47
34
|
case 'live':
|
|
48
|
-
return <LiveDurationText duration={duration}
|
|
35
|
+
return <LiveDurationText duration={duration} className={className} />;
|
|
49
36
|
default: {
|
|
50
37
|
const exhaustive: never = duration;
|
|
51
38
|
return exhaustive;
|
|
@@ -55,22 +42,25 @@ export function WorkflowRunDurationLabel({
|
|
|
55
42
|
|
|
56
43
|
export function useWorkflowRunDurationAccessibleLabel(
|
|
57
44
|
duration: WorkflowRunDurationDisplay | null,
|
|
45
|
+
hasStarted: boolean,
|
|
58
46
|
): string | undefined {
|
|
59
47
|
useTimeTick();
|
|
60
|
-
return workflowRunDurationAccessibleLabel(duration);
|
|
48
|
+
return workflowRunDurationAccessibleLabel(duration, hasStarted);
|
|
61
49
|
}
|
|
62
50
|
|
|
63
51
|
export function workflowRunDurationAccessibleLabel(
|
|
64
52
|
duration: WorkflowRunDurationDisplay | null,
|
|
53
|
+
hasStarted: boolean,
|
|
65
54
|
): string | undefined {
|
|
66
55
|
if (duration === null) return undefined;
|
|
67
|
-
const kind = durationKind(duration);
|
|
68
56
|
|
|
69
57
|
switch (duration.state) {
|
|
70
58
|
case 'live':
|
|
71
|
-
return
|
|
72
|
-
case 'fixed':
|
|
73
|
-
|
|
59
|
+
return `running ${humanDuration(duration.fromIso)}`;
|
|
60
|
+
case 'fixed': {
|
|
61
|
+
const verb = hasStarted ? 'ran' : 'lasted';
|
|
62
|
+
return `${verb} ${formatFixedDurationLabel(duration.elapsed)}`;
|
|
63
|
+
}
|
|
74
64
|
default: {
|
|
75
65
|
const exhaustive: never = duration;
|
|
76
66
|
return exhaustive;
|
|
@@ -78,27 +68,17 @@ export function workflowRunDurationAccessibleLabel(
|
|
|
78
68
|
}
|
|
79
69
|
}
|
|
80
70
|
|
|
81
|
-
function durationKind(duration: WorkflowRunDurationDisplay): DurationKind {
|
|
82
|
-
return 'kind' in duration ? duration.kind : 'run';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
71
|
function LiveDurationText({
|
|
86
72
|
duration,
|
|
87
|
-
kind,
|
|
88
73
|
className,
|
|
89
74
|
}: {
|
|
90
75
|
duration: Extract<WorkflowRunDurationDisplay, {state: 'live'}>;
|
|
91
|
-
kind: DurationKind;
|
|
92
76
|
className?: string | undefined;
|
|
93
77
|
}) {
|
|
94
78
|
useTimeTick();
|
|
95
79
|
const display = humanDuration(duration.fromIso);
|
|
96
80
|
return (
|
|
97
|
-
<DurationText
|
|
98
|
-
className={className}
|
|
99
|
-
kind={kind}
|
|
100
|
-
ariaLabel={`${LIVE_VERB_BY_KIND[kind]} ${display}`}
|
|
101
|
-
>
|
|
81
|
+
<DurationText className={className} ariaLabel={`running ${display}`}>
|
|
102
82
|
{display}
|
|
103
83
|
</DurationText>
|
|
104
84
|
);
|
|
@@ -107,12 +87,10 @@ function LiveDurationText({
|
|
|
107
87
|
function DurationText({
|
|
108
88
|
children,
|
|
109
89
|
className,
|
|
110
|
-
kind,
|
|
111
90
|
ariaLabel,
|
|
112
91
|
}: {
|
|
113
92
|
children: string;
|
|
114
93
|
className?: string | undefined;
|
|
115
|
-
kind: DurationKind;
|
|
116
94
|
ariaLabel?: string | undefined;
|
|
117
95
|
}) {
|
|
118
96
|
return (
|
|
@@ -125,10 +103,9 @@ function DurationText({
|
|
|
125
103
|
className,
|
|
126
104
|
)}
|
|
127
105
|
>
|
|
128
|
-
{/* The
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<Icon name={GLYPH_BY_KIND[kind]} className="size-12 shrink-0" aria-hidden="true" />
|
|
106
|
+
{/* The verb rides the accessible name: the numeric columns are fixed width and the
|
|
107
|
+
status badge already says whether the run is still going. */}
|
|
108
|
+
<Icon name="timerLine" className="size-12 shrink-0" aria-hidden="true" />
|
|
132
109
|
{children}
|
|
133
110
|
</Code>
|
|
134
111
|
);
|
|
@@ -470,6 +470,7 @@ describe('WorkflowRunListView', () => {
|
|
|
470
470
|
run('succeeded', 'build-image', 'run-build-image', {
|
|
471
471
|
started_at: '2026-05-07T01:00:00.000Z',
|
|
472
472
|
finished_at: '2026-05-07T01:02:14.000Z',
|
|
473
|
+
has_started_job_execution: true,
|
|
473
474
|
}),
|
|
474
475
|
]);
|
|
475
476
|
|
|
@@ -496,6 +497,53 @@ describe('WorkflowRunListView', () => {
|
|
|
496
497
|
expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
|
|
497
498
|
});
|
|
498
499
|
|
|
500
|
+
test('uses the attempt status when all listed jobs are pending', async () => {
|
|
501
|
+
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
|
|
502
|
+
|
|
503
|
+
renderListView([
|
|
504
|
+
run('running', 'waiting-for-runner', 'run-waiting-for-runner', {
|
|
505
|
+
...workflowRunJobsFixture(['pending', 'pending']),
|
|
506
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
507
|
+
finished_at: null,
|
|
508
|
+
}),
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
const duration = await screen.findByText('2m 14s');
|
|
512
|
+
expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
|
|
513
|
+
expect(
|
|
514
|
+
screen.getByRole('link', {
|
|
515
|
+
name: (name) => name.includes('Running') && name.includes('running 2m 14s'),
|
|
516
|
+
}),
|
|
517
|
+
).toBeInTheDocument();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('uses a neutral verb when no listed job execution started', async () => {
|
|
521
|
+
renderListView([
|
|
522
|
+
run('cancelled', 'cancelled-before-runner', 'run-cancelled-before-runner', {
|
|
523
|
+
...workflowRunJobsFixture(['pending']),
|
|
524
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
525
|
+
finished_at: '2026-05-07T01:02:14.000Z',
|
|
526
|
+
}),
|
|
527
|
+
]);
|
|
528
|
+
|
|
529
|
+
const duration = await screen.findByText('2m 14s');
|
|
530
|
+
expect(duration).toHaveAttribute('aria-label', 'lasted 2m 14s');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('uses the started-execution flag when a cancelled job reached a runner', async () => {
|
|
534
|
+
renderListView([
|
|
535
|
+
run('cancelled', 'cancelled-after-start', 'run-cancelled-after-start', {
|
|
536
|
+
...workflowRunJobsFixture(['cancelled']),
|
|
537
|
+
has_started_job_execution: true,
|
|
538
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
539
|
+
finished_at: '2026-05-07T01:02:14.000Z',
|
|
540
|
+
}),
|
|
541
|
+
]);
|
|
542
|
+
|
|
543
|
+
const duration = await screen.findByText('2m 14s');
|
|
544
|
+
expect(duration).toHaveAttribute('aria-label', 'ran 2m 14s');
|
|
545
|
+
});
|
|
546
|
+
|
|
499
547
|
test('renders the workflow name beside the run number', async () => {
|
|
500
548
|
renderListView([
|
|
501
549
|
run('succeeded', 'Deploy production', 'run-deploy-production', {
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
workflowRunActor,
|
|
21
21
|
workflowRunBranchLabel,
|
|
22
22
|
workflowRunCommitLabel,
|
|
23
|
-
workflowRunListItemDisplay,
|
|
24
23
|
} from '#core/workflow-run.js';
|
|
25
24
|
import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
|
|
26
25
|
import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
@@ -69,9 +68,11 @@ export function WorkflowRunRow({
|
|
|
69
68
|
workspaceSlug?: string | undefined;
|
|
70
69
|
projectSlug?: string | undefined;
|
|
71
70
|
}) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
71
|
+
const duration = run.runAttempt.displayDuration;
|
|
72
|
+
const hasStarted = run.jobs.hasStartedJobExecution;
|
|
73
|
+
const durationLabel = useWorkflowRunDurationAccessibleLabel(duration, hasStarted);
|
|
74
|
+
const status = run.runAttempt.status;
|
|
75
|
+
const statusLabel = getWorkflowStatusVisual(status).label;
|
|
75
76
|
const runNumberLabel = formatWorkflowRunNumberLabel(run);
|
|
76
77
|
const branch = workflowRunBranchLabel(run);
|
|
77
78
|
const commit = workflowRunCommitLabel(run);
|
|
@@ -79,7 +80,7 @@ export function WorkflowRunRow({
|
|
|
79
80
|
|
|
80
81
|
const body = (
|
|
81
82
|
<>
|
|
82
|
-
<WorkflowStatusIcon status={
|
|
83
|
+
<WorkflowStatusIcon status={status} size={14} className="shrink-0" />
|
|
83
84
|
|
|
84
85
|
<div className="flex min-w-0 flex-1 flex-col gap-tight @min-[976px]:flex-row @min-[976px]:items-center @min-[976px]:gap-cluster">
|
|
85
86
|
<span className="flex min-w-0 items-center gap-inline @min-[976px]:flex-1">
|
|
@@ -105,7 +106,7 @@ export function WorkflowRunRow({
|
|
|
105
106
|
<JobStatusStrip jobs={run.jobs} />
|
|
106
107
|
</span>
|
|
107
108
|
<span className="flex w-64 justify-end">
|
|
108
|
-
<WorkflowRunDurationLabel duration={
|
|
109
|
+
<WorkflowRunDurationLabel duration={duration} hasStarted={hasStarted} />
|
|
109
110
|
</span>
|
|
110
111
|
<Code
|
|
111
112
|
variant="label"
|
|
@@ -18,7 +18,6 @@ import {WorkflowRunAttempt, type WorkflowRunStatus} from '#core/workflow-run.js'
|
|
|
18
18
|
import {
|
|
19
19
|
runAttemptsResponseDto,
|
|
20
20
|
workflowJobDto,
|
|
21
|
-
workflowJobExecutionDto,
|
|
22
21
|
workflowRunAttemptDto,
|
|
23
22
|
workflowRunDetail,
|
|
24
23
|
} from '#test/fixtures/workflow-run.js';
|
|
@@ -203,19 +202,6 @@ export const Durations: Story = {
|
|
|
203
202
|
),
|
|
204
203
|
};
|
|
205
204
|
|
|
206
|
-
/**
|
|
207
|
-
* A live attempt whose jobs have not started. The header reports the wait rather than a run
|
|
208
|
-
* that never began, and names the job the run is blocked on once there is more than one.
|
|
209
|
-
*/
|
|
210
|
-
export const Queued: Story = {
|
|
211
|
-
render: () => (
|
|
212
|
-
<div className="flex flex-col">
|
|
213
|
-
<WorkflowRunSummary run={queuedRun('release-queued', ['build'])} />
|
|
214
|
-
<WorkflowRunSummary run={queuedRun('release-queued-fan-out', ['build', 'lint', 'test'])} />
|
|
215
|
-
</div>
|
|
216
|
-
),
|
|
217
|
-
};
|
|
218
|
-
|
|
219
205
|
export const Cancellable: Story = {
|
|
220
206
|
args: {
|
|
221
207
|
run: workflowRunDetail({status: 'running'}),
|
|
@@ -223,30 +209,6 @@ export const Cancellable: Story = {
|
|
|
223
209
|
},
|
|
224
210
|
};
|
|
225
211
|
|
|
226
|
-
function queuedRun(name: string, jobNames: string[]) {
|
|
227
|
-
return workflowRunDetail({
|
|
228
|
-
status: 'running',
|
|
229
|
-
name,
|
|
230
|
-
run_attempt: workflowRunAttemptDto({
|
|
231
|
-
status: 'running',
|
|
232
|
-
created_at: RUN_STARTED_AT,
|
|
233
|
-
started_at: RUN_STARTED_AT,
|
|
234
|
-
finished_at: null,
|
|
235
|
-
}),
|
|
236
|
-
jobs: jobNames.map((jobName, index) =>
|
|
237
|
-
workflowJobDto({
|
|
238
|
-
name: jobName,
|
|
239
|
-
status: 'pending',
|
|
240
|
-
job_executions: [
|
|
241
|
-
workflowJobExecutionDto({
|
|
242
|
-
queued_at: new Date(Date.parse(RUN_STARTED_AT) + index * 1000).toISOString(),
|
|
243
|
-
}),
|
|
244
|
-
],
|
|
245
|
-
}),
|
|
246
|
-
),
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
212
|
export const Cancelling: Story = {
|
|
251
213
|
args: {
|
|
252
214
|
run: workflowRunDetail({status: 'running'}),
|
|
@@ -159,6 +159,17 @@ describe('WorkflowRunSummary', () => {
|
|
|
159
159
|
test('shows a live selected attempt duration for running runs', async () => {
|
|
160
160
|
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
|
|
161
161
|
renderSummary({
|
|
162
|
+
jobs: [
|
|
163
|
+
workflowJobDto({
|
|
164
|
+
status: 'running',
|
|
165
|
+
job_executions: [
|
|
166
|
+
workflowJobExecutionDto({
|
|
167
|
+
status: 'running',
|
|
168
|
+
started_at: '2026-05-07T01:02:00.000Z',
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
],
|
|
162
173
|
run_attempt: {
|
|
163
174
|
id: '11111111-1111-4111-8111-000000000001',
|
|
164
175
|
workflow_run_id: RUN_ID,
|
|
@@ -178,7 +189,9 @@ describe('WorkflowRunSummary', () => {
|
|
|
178
189
|
expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
|
|
179
190
|
});
|
|
180
191
|
|
|
181
|
-
|
|
192
|
+
// The header reports the attempt the API returned. Whether a job has reached a runner yet is
|
|
193
|
+
// the job surfaces' business, so an attempt whose jobs are all pending still reads as running.
|
|
194
|
+
test('reads a running attempt as running whatever its jobs have reached', async () => {
|
|
182
195
|
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
|
|
183
196
|
renderSummary({
|
|
184
197
|
jobs: [workflowJobDto({status: 'pending'})],
|
|
@@ -187,50 +200,89 @@ describe('WorkflowRunSummary', () => {
|
|
|
187
200
|
|
|
188
201
|
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
189
202
|
|
|
190
|
-
expect(within(summary).getAllByText('
|
|
191
|
-
expect(within(summary).queryByText('
|
|
192
|
-
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', '
|
|
203
|
+
expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
|
|
204
|
+
expect(within(summary).queryByText('Queued')).not.toBeInTheDocument();
|
|
205
|
+
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'running 2m 14s');
|
|
193
206
|
});
|
|
194
207
|
|
|
195
|
-
test('
|
|
208
|
+
test('uses a neutral verb when an attempt finished before any job execution started', async () => {
|
|
196
209
|
renderSummary({
|
|
210
|
+
status: 'cancelled',
|
|
197
211
|
jobs: [
|
|
198
212
|
workflowJobDto({
|
|
199
|
-
name: 'lint',
|
|
200
213
|
status: 'pending',
|
|
201
214
|
job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
|
|
202
215
|
}),
|
|
203
|
-
workflowJobDto({
|
|
204
|
-
name: 'build',
|
|
205
|
-
status: 'pending',
|
|
206
|
-
job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T00:59:00.000Z'})],
|
|
207
|
-
}),
|
|
208
216
|
],
|
|
209
|
-
run_attempt:
|
|
217
|
+
run_attempt: {
|
|
218
|
+
id: '11111111-1111-4111-8111-000000000001',
|
|
219
|
+
workflow_run_id: RUN_ID,
|
|
220
|
+
attempt: 1,
|
|
221
|
+
status: 'cancelled',
|
|
222
|
+
created_at: '2026-05-07T01:00:00.000Z',
|
|
223
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
224
|
+
finished_at: '2026-05-07T01:02:14.000Z',
|
|
225
|
+
rerun_mode: null,
|
|
226
|
+
},
|
|
210
227
|
});
|
|
211
228
|
|
|
212
229
|
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
213
230
|
|
|
214
|
-
expect(within(summary).
|
|
215
|
-
expect(within(summary).getByText('
|
|
231
|
+
expect(within(summary).getAllByText('Cancelled')).not.toHaveLength(0);
|
|
232
|
+
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
|
|
216
233
|
});
|
|
217
234
|
|
|
218
|
-
test('
|
|
235
|
+
test('uses the run verb when a cancelled job execution started', async () => {
|
|
219
236
|
renderSummary({
|
|
237
|
+
status: 'cancelled',
|
|
220
238
|
jobs: [
|
|
221
239
|
workflowJobDto({
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
status: 'cancelled',
|
|
241
|
+
job_executions: [
|
|
242
|
+
workflowJobExecutionDto({
|
|
243
|
+
status: 'cancelled',
|
|
244
|
+
started_at: '2026-05-07T01:00:05.000Z',
|
|
245
|
+
}),
|
|
246
|
+
],
|
|
225
247
|
}),
|
|
226
248
|
],
|
|
227
|
-
run_attempt:
|
|
249
|
+
run_attempt: {
|
|
250
|
+
id: '11111111-1111-4111-8111-000000000001',
|
|
251
|
+
workflow_run_id: RUN_ID,
|
|
252
|
+
attempt: 1,
|
|
253
|
+
status: 'cancelled',
|
|
254
|
+
created_at: '2026-05-07T01:00:00.000Z',
|
|
255
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
256
|
+
finished_at: '2026-05-07T01:02:14.000Z',
|
|
257
|
+
rerun_mode: null,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
262
|
+
|
|
263
|
+
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'ran 2m 14s');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('uses a neutral verb when every job was skipped', async () => {
|
|
267
|
+
renderSummary({
|
|
268
|
+
status: 'succeeded',
|
|
269
|
+
jobs: [workflowJobDto({status: 'skipped'})],
|
|
270
|
+
run_attempt: {
|
|
271
|
+
id: '11111111-1111-4111-8111-000000000001',
|
|
272
|
+
workflow_run_id: RUN_ID,
|
|
273
|
+
attempt: 1,
|
|
274
|
+
status: 'succeeded',
|
|
275
|
+
created_at: '2026-05-07T01:00:00.000Z',
|
|
276
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
277
|
+
finished_at: '2026-05-07T01:02:14.000Z',
|
|
278
|
+
rerun_mode: null,
|
|
279
|
+
},
|
|
228
280
|
});
|
|
229
281
|
|
|
230
282
|
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
231
283
|
|
|
232
|
-
expect(within(summary).getAllByText('
|
|
233
|
-
expect(within(summary).
|
|
284
|
+
expect(within(summary).getAllByText('Succeeded')).not.toHaveLength(0);
|
|
285
|
+
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
|
|
234
286
|
});
|
|
235
287
|
|
|
236
288
|
test('does not render a whole-run source control', async () => {
|
|
@@ -12,7 +12,7 @@ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
|
|
|
12
12
|
import {RelativeTime} from '@shipfox/react-ui/relative-time';
|
|
13
13
|
import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
|
|
14
14
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
15
|
-
import {
|
|
15
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
16
16
|
import {useId} from 'react';
|
|
17
17
|
import {WorkflowRunNumberLabel} from '#components/workflow-run-number-label.js';
|
|
18
18
|
import {
|
|
@@ -21,17 +21,13 @@ import {
|
|
|
21
21
|
WORKFLOW_RUN_STATUSES,
|
|
22
22
|
type WorkflowRunDetail,
|
|
23
23
|
type WorkflowRunRerunMode,
|
|
24
|
-
workflowRunBlockingJob,
|
|
25
|
-
workflowRunDetailDisplay,
|
|
26
24
|
} from '#core/workflow-run.js';
|
|
27
25
|
import {WorkflowRunDurationLabel} from '../workflow-run-duration-label.js';
|
|
28
26
|
import {getWorkflowStatusVisual} from '../workflow-status/status-visuals.js';
|
|
29
27
|
import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
|
|
30
28
|
|
|
31
29
|
const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
|
|
32
|
-
...
|
|
33
|
-
(status) => getWorkflowStatusVisual(status).label.length,
|
|
34
|
-
),
|
|
30
|
+
...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
|
|
35
31
|
);
|
|
36
32
|
const RERUN_BUTTON_SURFACE_CLASS_NAME =
|
|
37
33
|
'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
|
|
@@ -60,19 +56,15 @@ export function WorkflowRunSummary({
|
|
|
60
56
|
latestAttempt,
|
|
61
57
|
}: WorkflowRunSummaryProps) {
|
|
62
58
|
const headingId = useId();
|
|
63
|
-
const
|
|
64
|
-
const status = getWorkflowStatusVisual(display.status);
|
|
59
|
+
const status = getWorkflowStatusVisual(run.runAttempt.status);
|
|
65
60
|
const action = workflowRunActionForRun(run);
|
|
66
61
|
const hasAction = canRenderWorkflowRunAction(action, onCancel, onRerun);
|
|
67
62
|
const attemptSwitcher =
|
|
68
63
|
latestAttempt && latestAttempt > 1 && workspaceSlug && projectSlug
|
|
69
64
|
? {workspaceSlug, projectSlug, latestAttempt}
|
|
70
65
|
: null;
|
|
71
|
-
const displayDuration =
|
|
72
|
-
|
|
73
|
-
// and the graph already say which one it is.
|
|
74
|
-
const blockingJob =
|
|
75
|
-
display.status === 'queued' && run.jobs.length > 1 ? workflowRunBlockingJob(run.jobs) : null;
|
|
66
|
+
const displayDuration = run.runAttempt.displayDuration;
|
|
67
|
+
const hasStarted = run.hasStartedJobExecution;
|
|
76
68
|
const {ref: headingTextRef, isTruncated: isHeadingTruncated} =
|
|
77
69
|
useIsTextTruncated<HTMLSpanElement>(run.name);
|
|
78
70
|
|
|
@@ -184,24 +176,11 @@ export function WorkflowRunSummary({
|
|
|
184
176
|
<MetadataSeparator />
|
|
185
177
|
<WorkflowRunDurationLabel
|
|
186
178
|
duration={displayDuration}
|
|
179
|
+
hasStarted={hasStarted}
|
|
187
180
|
className="text-foreground-neutral-subtle"
|
|
188
181
|
/>
|
|
189
182
|
</>
|
|
190
183
|
) : null}
|
|
191
|
-
|
|
192
|
-
{blockingJob ? (
|
|
193
|
-
<>
|
|
194
|
-
<MetadataSeparator />
|
|
195
|
-
<span className="flex min-w-0 items-center gap-tight">
|
|
196
|
-
<Text as="span" size="xs" className="shrink-0">
|
|
197
|
-
waiting on
|
|
198
|
-
</Text>
|
|
199
|
-
<Code as="span" variant="label" className="min-w-0 truncate">
|
|
200
|
-
{blockingJob.displayName}
|
|
201
|
-
</Code>
|
|
202
|
-
</span>
|
|
203
|
-
</>
|
|
204
|
-
) : null}
|
|
205
184
|
</div>
|
|
206
185
|
</div>
|
|
207
186
|
</section>
|
|
@@ -18,10 +18,6 @@ export function getWorkflowStatusVisual(status: WorkflowDisplayStatus): Workflow
|
|
|
18
18
|
switch (status) {
|
|
19
19
|
case 'pending':
|
|
20
20
|
return {kind: 'pending', label: 'Pending', dot: 'neutral', badge: 'neutral'};
|
|
21
|
-
// Waiting on a runner is the awaiting-* state the warning tone was held for: the run is
|
|
22
|
-
// live but nothing is executing, which is the operator's problem to see, not ours to hide.
|
|
23
|
-
case 'queued':
|
|
24
|
-
return {kind: 'queued', label: 'Queued', dot: 'warning', badge: 'warning'};
|
|
25
21
|
case 'running':
|
|
26
22
|
return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
|
|
27
23
|
case 'listening':
|
|
@@ -89,24 +89,6 @@ export function WorkflowStatusIcon({
|
|
|
89
89
|
<Icon name="pulseLine" size={Math.max(8, Math.round(size * 0.7))} className="text-white" />
|
|
90
90
|
</span>
|
|
91
91
|
);
|
|
92
|
-
} else if (visual.kind === 'queued') {
|
|
93
|
-
// An hourglass in the same filled disc the listening state uses, so waiting reads as its
|
|
94
|
-
// own shape rather than as a pending ring that happens to be a different colour.
|
|
95
|
-
glyph = (
|
|
96
|
-
<span
|
|
97
|
-
className={cn(
|
|
98
|
-
'inline-flex shrink-0 items-center justify-center rounded-full bg-current',
|
|
99
|
-
box,
|
|
100
|
-
toneByVariant.warning,
|
|
101
|
-
)}
|
|
102
|
-
>
|
|
103
|
-
<Icon
|
|
104
|
-
name="hourglassLine"
|
|
105
|
-
size={Math.max(8, Math.round(size * 0.7))}
|
|
106
|
-
className="text-white"
|
|
107
|
-
/>
|
|
108
|
-
</span>
|
|
109
|
-
);
|
|
110
92
|
} else if (visual.kind === 'running') {
|
|
111
93
|
glyph = <Dot variant="info" ripple={ripple} className={box} />;
|
|
112
94
|
} else if (visual.kind === 'pending') {
|