@shipfox/client-workflows 22.0.3 → 23.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "22.0.3",
4
+ "version": "23.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,13 +28,13 @@
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
- "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "13.1.0",
31
+ "@shipfox/api-definitions-dto": "14.0.0",
32
+ "@shipfox/api-workflows-dto": "14.0.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
34
  "@shipfox/client-logs": "22.0.3",
35
- "@shipfox/client-projects": "22.0.3",
36
- "@shipfox/client-shell": "22.0.3",
37
- "@shipfox/client-triggers": "22.0.3",
35
+ "@shipfox/client-projects": "23.0.0",
36
+ "@shipfox/client-shell": "23.0.0",
37
+ "@shipfox/client-triggers": "23.0.0",
38
38
  "@shipfox/client-ui": "22.0.3",
39
39
  "@shipfox/react-ui": "2.2.0"
40
40
  },
@@ -28,13 +28,13 @@ export type JobStatusReason =
28
28
  export interface JobListening {
29
29
  on: Array<{
30
30
  source: string;
31
- event: string;
31
+ event?: string | undefined;
32
32
  inputs?: Record<string, unknown> | undefined;
33
33
  filter?: string | undefined;
34
34
  }>;
35
35
  until: Array<{
36
36
  source: string;
37
- event: string;
37
+ event?: string | undefined;
38
38
  inputs?: Record<string, unknown> | undefined;
39
39
  filter?: string | undefined;
40
40
  }> | null;
@@ -1,5 +1,5 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
- import {fireEvent, screen, waitFor} from '@testing-library/react';
2
+ import {fireEvent, screen, waitFor, within} from '@testing-library/react';
3
3
  import {
4
4
  jsonResponse,
5
5
  PROJECT_TEST_WID,
@@ -69,11 +69,12 @@ describe('ProjectWorkflowsPage', () => {
69
69
  finished_at: null,
70
70
  last_error_code: 'no-workflow-files',
71
71
  last_error_message: 'No workflow files found',
72
- warnings: [
72
+ diagnostics: [
73
73
  {
74
74
  code: 're-evaluating-command',
75
75
  message: 'Workflow data is re-executed as shell code.',
76
76
  path: 'jobs.build.steps.0.run',
77
+ severity: 'warning',
77
78
  },
78
79
  ],
79
80
  },
@@ -104,16 +105,20 @@ describe('ProjectWorkflowsPage', () => {
104
105
  finished_at: '2026-05-07T01:00:00.000Z',
105
106
  last_error_code: null,
106
107
  last_error_message: null,
107
- warnings: [
108
+ diagnostics: [
108
109
  {
109
110
  code: 're-evaluating-command',
110
111
  message: 'Workflow data is re-executed as shell code.',
111
112
  path: 'jobs.build.steps.0.run',
113
+ file_path: '.shipfox/workflows/warning.yml',
114
+ severity: 'warning',
112
115
  },
113
116
  {
114
117
  code: 're-evaluating-command',
115
118
  message: 'Workflow data is re-executed as shell code.',
116
119
  path: 'jobs.build.steps.0.run',
120
+ file_path: '.shipfox/workflows/warning.yml',
121
+ severity: 'warning',
117
122
  },
118
123
  ],
119
124
  },
@@ -125,12 +130,87 @@ describe('ProjectWorkflowsPage', () => {
125
130
  renderWorkflowsPage();
126
131
 
127
132
  expect(await screen.findByText('Workflow definition warnings')).toBeInTheDocument();
133
+ // Both warnings group under the shared workflow file; the file label renders once.
128
134
  expect(screen.getAllByText('Workflow data is re-executed as shell code.')).toHaveLength(2);
129
- expect(screen.getAllByRole('listitem')).toHaveLength(2);
135
+ expect(screen.getAllByText('.shipfox/workflows/warning.yml')).toHaveLength(1);
130
136
  expect(screen.getAllByText('jobs.build.steps.0.run')).toHaveLength(2);
137
+ expect(screen.getByRole('status')).toBeInTheDocument();
131
138
  expect(screen.queryByText('Workflow sync failed')).not.toBeInTheDocument();
132
139
  });
133
140
 
141
+ test('shows errors and warnings together with severity labels, grouped by file path', async () => {
142
+ configureApiClient({
143
+ fetchImpl: createProjectDetailFetch({
144
+ definitions: jsonResponse(
145
+ definitionsDto({
146
+ sync: {
147
+ ref: 'main',
148
+ status: 'succeeded',
149
+ last_sync_at: '2026-05-07T01:00:00.000Z',
150
+ started_at: '2026-05-07T00:59:55.000Z',
151
+ finished_at: '2026-05-07T01:00:00.000Z',
152
+ last_error_code: null,
153
+ last_error_message: null,
154
+ diagnostics: [
155
+ {
156
+ code: 'invalid-trigger-event',
157
+ message: 'Trigger event is never delivered by this source.',
158
+ path: 'triggers.on_deploy',
159
+ file_path: '.shipfox/workflows/deploy.yml',
160
+ severity: 'error',
161
+ },
162
+ {
163
+ code: 'unknown-trigger-source',
164
+ message: 'No connection matches this source slug.',
165
+ path: 'triggers.on_deploy',
166
+ file_path: '.shipfox/workflows/deploy.yml',
167
+ severity: 'warning',
168
+ },
169
+ {
170
+ code: 're-evaluating-command',
171
+ message: 'Workflow data is re-executed as shell code.',
172
+ path: 'jobs.build.steps.0.run',
173
+ file_path: '.shipfox/workflows/build.yml',
174
+ severity: 'warning',
175
+ },
176
+ ],
177
+ },
178
+ }),
179
+ ),
180
+ }),
181
+ });
182
+
183
+ renderWorkflowsPage();
184
+
185
+ expect(await screen.findByText('Workflow definition diagnostics')).toBeInTheDocument();
186
+ const diagnosticsCallout = screen.getByRole('status');
187
+ expect(diagnosticsCallout).toBeInTheDocument();
188
+ // Two groups: the deploy workflow file and the build workflow file.
189
+ expect(within(diagnosticsCallout).getAllByText('.shipfox/workflows/deploy.yml')).toHaveLength(
190
+ 1,
191
+ );
192
+ expect(within(diagnosticsCallout).getAllByText('.shipfox/workflows/build.yml')).toHaveLength(1);
193
+ expect(within(diagnosticsCallout).getAllByText('triggers.on_deploy')).toHaveLength(2);
194
+ expect(within(diagnosticsCallout).getAllByText('jobs.build.steps.0.run')).toHaveLength(1);
195
+ const errorRow = within(diagnosticsCallout).getByText(
196
+ 'Trigger event is never delivered by this source.',
197
+ );
198
+ const warningRow = within(diagnosticsCallout).getByText(
199
+ 'No connection matches this source slug.',
200
+ );
201
+ expect(errorRow).toHaveClass('text-tag-error-text');
202
+ expect(warningRow).not.toHaveClass('text-tag-error-text');
203
+ expect(
204
+ within(diagnosticsCallout).getByText('Error:', {selector: 'span.font-medium'}),
205
+ ).toBeInTheDocument();
206
+ expect(
207
+ within(diagnosticsCallout).getAllByText('Warning:', {selector: 'span.font-medium'}),
208
+ ).toHaveLength(2);
209
+ expect(
210
+ within(diagnosticsCallout).getByText('Workflow data is re-executed as shell code.'),
211
+ ).toBeInTheDocument();
212
+ });
213
+
134
214
  test('opens and closes the definition drawer by clicking the row', async () => {
135
215
  configureApiClient({fetchImpl: createProjectDetailFetch()});
136
216
 
@@ -301,7 +381,7 @@ function baseDefinitionsDto() {
301
381
  finished_at: '2026-05-07T01:00:00.000Z',
302
382
  last_error_code: null,
303
383
  last_error_message: null,
304
- warnings: [],
384
+ diagnostics: [],
305
385
  },
306
386
  };
307
387
  }
@@ -1,6 +1,7 @@
1
1
  import {ApiError} from '@shipfox/client-api';
2
2
  import {
3
3
  type Definition,
4
+ type DefinitionSyncDiagnostic,
4
5
  type DefinitionSyncSummary,
5
6
  SourceStrip,
6
7
  useDefinitionsInfiniteQuery,
@@ -101,7 +102,7 @@ function ProjectWorkflowsPageInner({projectId}: {projectId: string}) {
101
102
  />
102
103
 
103
104
  <WorkflowSyncAlert sync={sync} />
104
- <WorkflowSyncWarnings sync={sync} />
105
+ <WorkflowSyncDiagnostics sync={sync} />
105
106
 
106
107
  <WorkflowDefinitionsList
107
108
  definitions={definitions}
@@ -382,30 +383,54 @@ function WorkflowSyncAlert({sync}: {sync: DefinitionSyncSummary | null | undefin
382
383
  );
383
384
  }
384
385
 
385
- function WorkflowSyncWarnings({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
386
- if (sync?.status !== 'succeeded' || sync.warnings.length === 0) return null;
386
+ function WorkflowSyncDiagnostics({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
387
+ if (sync?.status !== 'succeeded' || sync.diagnostics.length === 0) return null;
387
388
 
388
- const seenKeys = new Map<string, number>();
389
- const warningItems = sync.warnings.map((warning) => {
390
- const baseKey = `${warning.code}-${warning.path ?? 'workflow'}-${warning.message}`;
391
- const occurrence = seenKeys.get(baseKey) ?? 0;
392
- seenKeys.set(baseKey, occurrence + 1);
393
- return {key: `${baseKey}-${occurrence}`, warning};
394
- });
389
+ const hasErrors = sync.diagnostics.some((diagnostic) => diagnostic.severity === 'error');
390
+ const hasWarnings = sync.diagnostics.some((diagnostic) => diagnostic.severity === 'warning');
391
+ const groups = groupDiagnosticsByFilePath(sync.diagnostics);
392
+ const title =
393
+ hasErrors && hasWarnings
394
+ ? 'Workflow definition diagnostics'
395
+ : hasErrors
396
+ ? 'Workflow definition errors'
397
+ : 'Workflow definition warnings';
395
398
 
396
399
  return (
397
- <Callout role="status" type="warning">
400
+ <Callout role="status" type={hasErrors ? 'error' : 'warning'}>
398
401
  <div className="flex flex-col gap-inline">
399
402
  <Text size="sm" bold>
400
- Workflow definition warnings
403
+ {title}
401
404
  </Text>
402
405
  <ul className="flex flex-col gap-tight">
403
- {warningItems.map(({key, warning}) => (
404
- <li key={key}>
405
- <Text size="sm">{warning.message}</Text>
406
- {warning.path ? (
407
- <Code className="text-foreground-neutral-muted">{warning.path}</Code>
406
+ {groups.map((group) => (
407
+ <li key={group.key} className="flex flex-col gap-tight">
408
+ {group.filePath ? (
409
+ <Code className="text-foreground-neutral-muted">{group.filePath}</Code>
408
410
  ) : null}
411
+ <ul className="flex flex-col gap-tight">
412
+ {group.items.map(({key, diagnostic}) => {
413
+ const severityLabel = diagnostic.severity === 'error' ? 'Error' : 'Warning';
414
+
415
+ return (
416
+ <li key={key}>
417
+ {diagnostic.path ? (
418
+ <Code className="text-foreground-neutral-muted">{diagnostic.path}</Code>
419
+ ) : null}
420
+ <Text size="sm">
421
+ <span className="font-medium">{severityLabel}:</span>{' '}
422
+ <span
423
+ className={
424
+ diagnostic.severity === 'error' ? 'text-tag-error-text' : undefined
425
+ }
426
+ >
427
+ {diagnostic.message}
428
+ </span>
429
+ </Text>
430
+ </li>
431
+ );
432
+ })}
433
+ </ul>
409
434
  </li>
410
435
  ))}
411
436
  </ul>
@@ -414,6 +439,35 @@ function WorkflowSyncWarnings({sync}: {sync: DefinitionSyncSummary | null | unde
414
439
  );
415
440
  }
416
441
 
442
+ interface DiagnosticGroup {
443
+ key: string;
444
+ filePath: string | undefined;
445
+ items: {key: string; diagnostic: DefinitionSyncDiagnostic}[];
446
+ }
447
+
448
+ function groupDiagnosticsByFilePath(
449
+ diagnostics: readonly DefinitionSyncDiagnostic[],
450
+ ): DiagnosticGroup[] {
451
+ const groups: DiagnosticGroup[] = [];
452
+ const indexByFilePath = new Map<string, number>();
453
+ for (const diagnostic of diagnostics) {
454
+ const filePathKey = diagnostic.filePath ?? '';
455
+ const groupIndex = indexByFilePath.get(filePathKey);
456
+ if (groupIndex === undefined) {
457
+ indexByFilePath.set(filePathKey, groups.length);
458
+ groups.push({
459
+ key: `${filePathKey}-${groups.length}`,
460
+ filePath: diagnostic.filePath,
461
+ items: [{key: `${filePathKey}-0`, diagnostic}],
462
+ });
463
+ } else {
464
+ const group = groups[groupIndex];
465
+ if (group) group.items.push({key: `${filePathKey}-${group.items.length}`, diagnostic});
466
+ }
467
+ }
468
+ return groups;
469
+ }
470
+
417
471
  function DefinitionSheet({
418
472
  definition,
419
473
  onOpenChange,
@@ -66,6 +66,8 @@ export function workflowRunDto(
66
66
  name: 'deploy-web',
67
67
  workflow_name: 'deploy-web',
68
68
  status: 'running',
69
+ origin: 'synced',
70
+ dev_source: null,
69
71
  current_attempt: 1,
70
72
  latest_attempt: 1,
71
73
  trigger_provider: null,