@shipfox/client-workflows 18.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 +25 -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/job-status-strip.d.ts.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.js +20 -7
- package/dist/components/workflow-run-list/job-status-strip.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/job.d.ts +5 -2
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js +8 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +15 -57
- 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 +14 -2
- 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/job-graph/job-node.test.tsx +2 -2
- package/src/components/workflow-run-duration-label.tsx +21 -44
- package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +121 -0
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -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/job-status.test.ts +47 -1
- package/src/core/entities/job.ts +15 -3
- package/src/core/entities/workflow-run.ts +21 -134
- package/src/core/workflow-run.test.ts +15 -0
- package/src/core/workflow-run.ts +0 -8
- package/src/hooks/api/workflow-run-mapper.ts +20 -1
- package/src/hooks/api/workflow-runs.ts +1 -1
- package/test/fixtures/workflow-run.ts +60 -13
- package/tsconfig.build.tsbuildinfo +1 -1
- package/src/core/entities/workflow-run-display.test.ts +0 -159
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",
|
|
@@ -332,7 +332,7 @@ describe('JobNode status indicator', () => {
|
|
|
332
332
|
expect(screen.getByRole('button', {name: 'deploy-prod, Succeeded'})).toBeInTheDocument();
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
-
test('shows a running lifecycle without a running step as
|
|
335
|
+
test('shows a running lifecycle without a running step as running', () => {
|
|
336
336
|
const node = makeNode({
|
|
337
337
|
name: 'deploy',
|
|
338
338
|
status: 'pending',
|
|
@@ -341,7 +341,7 @@ describe('JobNode status indicator', () => {
|
|
|
341
341
|
|
|
342
342
|
renderNode(node);
|
|
343
343
|
|
|
344
|
-
expect(screen.getByRole('button', {name: 'deploy,
|
|
344
|
+
expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
|
|
345
345
|
});
|
|
346
346
|
|
|
347
347
|
test('shows a pending lifecycle as running while a step is active', () => {
|
|
@@ -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
|
);
|
|
@@ -3,7 +3,8 @@ import {Code, Text} from '@shipfox/react-ui/typography';
|
|
|
3
3
|
import {cn} from '@shipfox/react-ui/utils';
|
|
4
4
|
import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
|
|
5
5
|
import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
|
|
6
|
-
import
|
|
6
|
+
import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
|
|
7
|
+
import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* How many glyphs fit the strip's column before it starts costing the run name width.
|
|
@@ -33,16 +34,17 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
|
|
|
33
34
|
|
|
34
35
|
// Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
|
|
35
36
|
// A strip that hides a failure behind eight green discs would be worse than no strip at all.
|
|
36
|
-
const STATUS_SEVERITY: Record<
|
|
37
|
+
const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
|
|
37
38
|
failed: 5,
|
|
38
39
|
running: 4,
|
|
40
|
+
listening: 4,
|
|
39
41
|
pending: 3,
|
|
40
42
|
cancelled: 2,
|
|
41
43
|
skipped: 1,
|
|
42
44
|
succeeded: 0,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
|
-
const NOTABLE_STATUSES: readonly
|
|
47
|
+
const NOTABLE_STATUSES: readonly JobDisplayStatus[] = ['failed', 'running', 'listening'];
|
|
46
48
|
|
|
47
49
|
export interface JobStatusStripProps {
|
|
48
50
|
jobs: WorkflowRunJobs;
|
|
@@ -80,7 +82,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
|
|
|
80
82
|
{visible.map((job) => (
|
|
81
83
|
<WorkflowStatusIcon
|
|
82
84
|
key={job.id}
|
|
83
|
-
status={job
|
|
85
|
+
status={displayStatus(job)}
|
|
84
86
|
size={GLYPH_SIZE}
|
|
85
87
|
// One ripple per running job, on every row, would turn a calm list into a
|
|
86
88
|
// field of pulses. The run's own glyph already carries the live edge.
|
|
@@ -121,7 +123,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
|
|
|
121
123
|
// can be named, but the count of those left over is read off the totals, so a failure past
|
|
122
124
|
// the preview is still accounted for rather than silently missing.
|
|
123
125
|
const named = jobs.preview
|
|
124
|
-
.filter((job) => NOTABLE_STATUSES.includes(job
|
|
126
|
+
.filter((job) => NOTABLE_STATUSES.includes(displayStatus(job)))
|
|
125
127
|
.slice(0, MAX_TOOLTIP_JOB_NAMES);
|
|
126
128
|
const notableTotal = NOTABLE_STATUSES.reduce((total, status) => total + countOf(jobs, status), 0);
|
|
127
129
|
const remaining = notableTotal - named.length;
|
|
@@ -133,7 +135,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
|
|
|
133
135
|
</Text>
|
|
134
136
|
{named.map((job) => (
|
|
135
137
|
<Code as="span" variant="label" key={job.id} className="block truncate">
|
|
136
|
-
{getWorkflowStatusVisual(job
|
|
138
|
+
{getWorkflowStatusVisual(displayStatus(job)).label.toLowerCase()} · {job.name ?? job.key}
|
|
137
139
|
</Code>
|
|
138
140
|
))}
|
|
139
141
|
{remaining > 0 ? (
|
|
@@ -162,15 +164,16 @@ export function jobStatusSummary(jobs: WorkflowRunJobs): string {
|
|
|
162
164
|
function worstHiddenStatus(
|
|
163
165
|
jobs: WorkflowRunJobs,
|
|
164
166
|
visible: readonly WorkflowRunJobSummary[],
|
|
165
|
-
):
|
|
167
|
+
): JobDisplayStatus | null {
|
|
166
168
|
const hidden = new Map(jobs.statusCounts.map(({status, count}) => [status, count]));
|
|
167
169
|
for (const job of visible) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
const status = displayStatus(job);
|
|
171
|
+
const remaining = (hidden.get(status) ?? 0) - 1;
|
|
172
|
+
if (remaining > 0) hidden.set(status, remaining);
|
|
173
|
+
else hidden.delete(status);
|
|
171
174
|
}
|
|
172
175
|
|
|
173
|
-
let worst:
|
|
176
|
+
let worst: JobDisplayStatus | null = null;
|
|
174
177
|
for (const [status, count] of hidden) {
|
|
175
178
|
if (count <= 0) continue;
|
|
176
179
|
if (worst === null || STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) worst = status;
|
|
@@ -184,6 +187,16 @@ export function overflowCountLabel(hiddenCount: number): string {
|
|
|
184
187
|
: COMPACT_COUNT_FORMAT.format(hiddenCount);
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
function countOf(jobs: WorkflowRunJobs, status:
|
|
190
|
+
function countOf(jobs: WorkflowRunJobs, status: JobDisplayStatus): number {
|
|
188
191
|
return jobs.statusCounts.find((entry) => entry.status === status)?.count ?? 0;
|
|
189
192
|
}
|
|
193
|
+
|
|
194
|
+
function displayStatus(job: WorkflowRunJobSummary): JobDisplayStatus {
|
|
195
|
+
return deriveJobDisplayStatus({
|
|
196
|
+
mode: job.mode,
|
|
197
|
+
status: job.status,
|
|
198
|
+
listenerStatus: job.listenerStatus,
|
|
199
|
+
executionStatus: job.executionStatus,
|
|
200
|
+
jobExecutions: [],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -307,6 +307,53 @@ describe('WorkflowRunListView', () => {
|
|
|
307
307
|
).toBeInTheDocument();
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
test('shows an executing one-shot job instead of its pending verdict', async () => {
|
|
311
|
+
const {jobs} = workflowRunJobsFixture(['pending']);
|
|
312
|
+
renderListView([
|
|
313
|
+
run('running', 'deploy-web', 'run-1', {
|
|
314
|
+
jobs: jobs.map((job) => ({...job, execution_status: 'running'})),
|
|
315
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
316
|
+
job_display_status_counts: [{status: 'running', count: 1}],
|
|
317
|
+
}),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 running'});
|
|
321
|
+
expect(within(strip).getByLabelText('Running')).toBeInTheDocument();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('shows an active listener as listening in the job strip', async () => {
|
|
325
|
+
const {jobs} = workflowRunJobsFixture(['running']);
|
|
326
|
+
renderListView([
|
|
327
|
+
run('running', 'event-driven', 'run-1', {
|
|
328
|
+
jobs: jobs.map((job) => ({
|
|
329
|
+
...job,
|
|
330
|
+
mode: 'listening',
|
|
331
|
+
listener_status: 'listening',
|
|
332
|
+
execution_status: null,
|
|
333
|
+
})),
|
|
334
|
+
job_status_counts: [{status: 'running', count: 1}],
|
|
335
|
+
job_display_status_counts: [{status: 'listening', count: 1}],
|
|
336
|
+
}),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 listening'});
|
|
340
|
+
expect(within(strip).getByLabelText('Listening')).toBeInTheDocument();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('shows a failed execution when the job verdict is still pending', async () => {
|
|
344
|
+
const {jobs} = workflowRunJobsFixture(['pending']);
|
|
345
|
+
renderListView([
|
|
346
|
+
run('running', 'deploy-web', 'run-1', {
|
|
347
|
+
jobs: jobs.map((job) => ({...job, execution_status: 'failed'})),
|
|
348
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
349
|
+
job_display_status_counts: [{status: 'failed', count: 1}],
|
|
350
|
+
}),
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 failed'});
|
|
354
|
+
expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
|
|
355
|
+
});
|
|
356
|
+
|
|
310
357
|
// The link's aria-label replaces its contents, so the strip's own label is not spoken
|
|
311
358
|
// when the row is announced as a link. Where a run failed has to survive that.
|
|
312
359
|
test('carries the job breakdown into the row link name', async () => {
|
|
@@ -355,6 +402,32 @@ describe('WorkflowRunListView', () => {
|
|
|
355
402
|
expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
|
|
356
403
|
});
|
|
357
404
|
|
|
405
|
+
test('reports listening jobs in the overflow glyph and tooltip', async () => {
|
|
406
|
+
const {jobs} = workflowRunJobsFixture(
|
|
407
|
+
Array.from({length: 16}, () => 'pending') as JobStatusDto[],
|
|
408
|
+
);
|
|
409
|
+
renderListView([
|
|
410
|
+
run('running', 'event-driven', 'run-listener', {
|
|
411
|
+
jobs: jobs.map((job) => ({
|
|
412
|
+
...job,
|
|
413
|
+
mode: 'listening',
|
|
414
|
+
listener_status: 'listening',
|
|
415
|
+
execution_status: null,
|
|
416
|
+
})),
|
|
417
|
+
job_status_counts: [{status: 'pending', count: 20}],
|
|
418
|
+
job_display_status_counts: [{status: 'listening', count: 20}],
|
|
419
|
+
}),
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
|
|
423
|
+
expect(screen.getByText('+13')).toBeInTheDocument();
|
|
424
|
+
expect(within(strip).getAllByLabelText('Listening')).toHaveLength(8);
|
|
425
|
+
|
|
426
|
+
const user = userEvent.setup();
|
|
427
|
+
await user.hover(strip);
|
|
428
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent('and 14 more');
|
|
429
|
+
});
|
|
430
|
+
|
|
358
431
|
// Nothing caps a workflow's job count, so an exact overflow count is unbounded in width
|
|
359
432
|
// and would eventually paint over the duration column beside it.
|
|
360
433
|
test('abbreviates an overflow count too wide to print exactly', async () => {
|
|
@@ -397,6 +470,7 @@ describe('WorkflowRunListView', () => {
|
|
|
397
470
|
run('succeeded', 'build-image', 'run-build-image', {
|
|
398
471
|
started_at: '2026-05-07T01:00:00.000Z',
|
|
399
472
|
finished_at: '2026-05-07T01:02:14.000Z',
|
|
473
|
+
has_started_job_execution: true,
|
|
400
474
|
}),
|
|
401
475
|
]);
|
|
402
476
|
|
|
@@ -423,6 +497,53 @@ describe('WorkflowRunListView', () => {
|
|
|
423
497
|
expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
|
|
424
498
|
});
|
|
425
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
|
+
|
|
426
547
|
test('renders the workflow name beside the run number', async () => {
|
|
427
548
|
renderListView([
|
|
428
549
|
run('succeeded', 'Deploy production', 'run-deploy-production', {
|
|
@@ -101,6 +101,12 @@ type Story = StoryObj<typeof meta>;
|
|
|
101
101
|
|
|
102
102
|
export const Playground: Story = {};
|
|
103
103
|
|
|
104
|
+
export const ExecutionStates: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
runs: [makeExecutionStateRun(), makeListeningStateRun()],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* The row under its one-line threshold, at the 720px a 768px viewport leaves the column.
|
|
106
112
|
*
|
|
@@ -229,3 +235,28 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
|
|
|
229
235
|
</div>
|
|
230
236
|
);
|
|
231
237
|
}
|
|
238
|
+
|
|
239
|
+
function makeExecutionStateRun(): WorkflowRunListItem {
|
|
240
|
+
const fixture = workflowRunJobsFixture(['pending']);
|
|
241
|
+
return sequencedWorkflowRunListItem('running', 'one-shot-executing', 1, {
|
|
242
|
+
...fixture,
|
|
243
|
+
jobs: fixture.jobs.map((job) => ({...job, execution_status: 'running'})),
|
|
244
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
245
|
+
job_display_status_counts: [{status: 'running', count: 1}],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function makeListeningStateRun(): WorkflowRunListItem {
|
|
250
|
+
const fixture = workflowRunJobsFixture(['pending']);
|
|
251
|
+
return sequencedWorkflowRunListItem('running', 'event-driven-listener', 3, {
|
|
252
|
+
...fixture,
|
|
253
|
+
jobs: fixture.jobs.map((job) => ({
|
|
254
|
+
...job,
|
|
255
|
+
mode: 'listening',
|
|
256
|
+
listener_status: 'listening',
|
|
257
|
+
execution_status: null,
|
|
258
|
+
})),
|
|
259
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
260
|
+
job_display_status_counts: [{status: 'listening', count: 1}],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -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'}),
|