@shipfox/client-workflows 15.0.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.storybook/preview.css +4 -0
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/dist/components/job-detail/job-detail-header.d.ts +8 -1
  5. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
  6. package/dist/components/job-detail/job-detail-header.js +25 -12
  7. package/dist/components/job-detail/job-detail-header.js.map +1 -1
  8. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  9. package/dist/components/job-detail/job-detail-view.js +17 -2
  10. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  11. package/dist/components/workflow-run-summary/workflow-run-summary.js +1 -0
  12. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  13. package/dist/components/workflow-run-tabs/index.d.ts +3 -0
  14. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  15. package/dist/components/workflow-run-tabs/index.js +3 -0
  16. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  17. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
  18. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
  19. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
  20. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
  21. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
  22. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
  23. package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
  24. package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
  25. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
  26. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
  27. package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
  28. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
  29. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
  30. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  31. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
  32. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  33. package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
  34. package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
  35. package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
  36. package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
  37. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +1 -1
  38. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  39. package/dist/components/workflow-run-view/run-workspace-nav.js +9 -3
  40. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  41. package/dist/components/workflow-run-view/workflow-run-view.d.ts +1 -3
  42. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  43. package/dist/components/workflow-run-view/workflow-run-view.js +150 -52
  44. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  45. package/dist/core/run-annotation.d.ts +73 -0
  46. package/dist/core/run-annotation.d.ts.map +1 -0
  47. package/dist/core/run-annotation.js +125 -0
  48. package/dist/core/run-annotation.js.map +1 -0
  49. package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
  50. package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
  51. package/dist/hooks/api/run-annotation-mapper.js +22 -0
  52. package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
  53. package/dist/hooks/api/run-annotations.d.ts +25 -0
  54. package/dist/hooks/api/run-annotations.d.ts.map +1 -0
  55. package/dist/hooks/api/run-annotations.js +78 -0
  56. package/dist/hooks/api/run-annotations.js.map +1 -0
  57. package/dist/routes/inputs.d.ts +7 -2
  58. package/dist/routes/inputs.d.ts.map +1 -1
  59. package/dist/routes/inputs.js +5 -6
  60. package/dist/routes/inputs.js.map +1 -1
  61. package/dist/tsconfig.test.tsbuildinfo +1 -1
  62. package/package.json +10 -9
  63. package/src/components/job-detail/job-detail-header.test.tsx +3 -0
  64. package/src/components/job-detail/job-detail-header.tsx +30 -5
  65. package/src/components/job-detail/job-detail-view.stories.tsx +5 -0
  66. package/src/components/job-detail/job-detail-view.tsx +18 -1
  67. package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -1
  68. package/src/components/workflow-run-tabs/index.ts +3 -0
  69. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
  70. package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
  71. package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
  72. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
  73. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
  74. package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
  75. package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
  76. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +44 -0
  77. package/src/components/workflow-run-view/run-workspace-nav.tsx +15 -2
  78. package/src/components/workflow-run-view/workflow-run-view.test.tsx +246 -20
  79. package/src/components/workflow-run-view/workflow-run-view.tsx +148 -30
  80. package/src/core/run-annotation.test.ts +315 -0
  81. package/src/core/run-annotation.ts +238 -0
  82. package/src/hooks/api/run-annotation-mapper.ts +30 -0
  83. package/src/hooks/api/run-annotations.ts +137 -0
  84. package/src/pages/workflow-job-detail-page.test.tsx +66 -0
  85. package/src/routes/inputs.ts +7 -2
  86. package/tsconfig.build.tsbuildinfo +1 -1
  87. package/dist/core/workflow-run-tabs.d.ts +0 -9
  88. package/dist/core/workflow-run-tabs.d.ts.map +0 -1
  89. package/dist/core/workflow-run-tabs.js +0 -3
  90. package/dist/core/workflow-run-tabs.js.map +0 -1
  91. package/src/core/workflow-run-tabs.ts +0 -8
@@ -10,11 +10,13 @@ import {
10
10
  SelectValue,
11
11
  } from '@shipfox/react-ui/select';
12
12
  import {toast} from '@shipfox/react-ui/toast';
13
+ import {Text} from '@shipfox/react-ui/typography';
13
14
  import {useNavigate} from '@tanstack/react-router';
14
- import {type ReactNode, useEffect} from 'react';
15
- import type {Job, WorkflowRunRerunMode} from '#core/workflow-run.js';
16
- import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
15
+ import {type ReactNode, useEffect, useMemo} from 'react';
16
+ import {buildRunAnnotationList, type RunAnnotationSummary} from '#core/run-annotation.js';
17
+ import {isWorkflowRunTerminal, type Job, type WorkflowRunRerunMode} from '#core/workflow-run.js';
17
18
  import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
19
+ import {useRunAnnotationsQuery} from '#hooks/api/run-annotations.js';
18
20
  import {
19
21
  useCancelWorkflowRunMutation,
20
22
  useRerunWorkflowRunMutation,
@@ -30,7 +32,7 @@ import {
30
32
  import {JobGraph} from '../job-graph/index.js';
31
33
  import type {JobGraphSelectionSource} from '../job-graph/types.js';
32
34
  import {WorkflowRunSummary} from '../workflow-run-summary/index.js';
33
- import {RunAnnotationSummaryLine, RunAnnotationsEmpty} from '../workflow-run-tabs/index.js';
35
+ import {RunAnnotationList, RunAnnotationSummaryLine} from '../workflow-run-tabs/index.js';
34
36
  import {WorkflowSourceContent} from '../workflow-source-panel/index.js';
35
37
  import {RunWorkspaceNav} from './run-workspace-nav.js';
36
38
  import {resolveWorkflowRunSelection} from './workflow-run-selection.js';
@@ -51,7 +53,6 @@ export interface WorkflowRunViewProps {
51
53
  runAttempt?: number | undefined;
52
54
  selection?: WorkflowRunsSearch | undefined;
53
55
  tab?: WorkflowRunTab | undefined;
54
- annotationSummary?: RunAnnotationSummary | undefined;
55
56
  activeJobId?: string | undefined;
56
57
  jobSearch?: WorkflowJobSearch | undefined;
57
58
  jobContent?: ReactNode | undefined;
@@ -69,7 +70,6 @@ export function WorkflowRunView({
69
70
  runAttempt,
70
71
  selection,
71
72
  tab,
72
- annotationSummary,
73
73
  activeJobId,
74
74
  jobSearch,
75
75
  jobContent,
@@ -79,6 +79,22 @@ export function WorkflowRunView({
79
79
  runAttempt: selection?.runAttempt ?? runAttempt,
80
80
  });
81
81
  const rerunMutation = useRerunWorkflowRunMutation(projectId);
82
+ // Annotations are read on their own cadence rather than hydrated into the run, so the two
83
+ // modules stay decoupled. The key is shared, so the job page's count chip costs no extra fetch.
84
+ //
85
+ // Polling is scoped to the surface that renders bodies. The read has no counts-only mode, so
86
+ // every poll transfers whole Markdown bodies; doing that every four seconds behind Summary,
87
+ // Source, and the job log spends megabytes to keep one rail number warm. Off that surface the
88
+ // counts still refresh on navigation and on window focus.
89
+ const annotationsQuery = useRunAnnotationsQuery({
90
+ workflowRunId,
91
+ runAttempt: runQuery.data?.runAttempt.attempt,
92
+ live:
93
+ runWorkspaceSection(tab) === 'annotations' &&
94
+ !activeJobId &&
95
+ Boolean(runQuery.data) &&
96
+ !isWorkflowRunTerminal(runQuery.data?.runAttempt.status ?? 'pending'),
97
+ });
82
98
 
83
99
  return (
84
100
  <RelativeTimeProvider>
@@ -87,11 +103,11 @@ export function WorkflowRunView({
87
103
  workspaceSlug={workspaceSlug}
88
104
  projectSlug={projectSlug}
89
105
  query={runQuery}
106
+ annotations={annotationsQuery}
90
107
  rerunMutation={rerunMutation}
91
108
  runAttempt={runAttempt}
92
109
  selection={selection}
93
110
  tab={tab}
94
- annotationSummary={annotationSummary}
95
111
  activeJobId={activeJobId}
96
112
  jobSearch={jobSearch}
97
113
  jobContent={jobContent}
@@ -105,11 +121,11 @@ function RunViewContent({
105
121
  workspaceSlug,
106
122
  projectSlug,
107
123
  query,
124
+ annotations,
108
125
  rerunMutation,
109
126
  runAttempt,
110
127
  selection,
111
128
  tab,
112
- annotationSummary,
113
129
  activeJobId,
114
130
  jobSearch,
115
131
  jobContent,
@@ -117,17 +133,18 @@ function RunViewContent({
117
133
  workspaceSlug: string | undefined;
118
134
  projectSlug: string | undefined;
119
135
  query: ReturnType<typeof useWorkflowRunAttemptQuery>;
136
+ annotations: ReturnType<typeof useRunAnnotationsQuery>;
120
137
  rerunMutation: ReturnType<typeof useRerunWorkflowRunMutation>;
121
138
  runAttempt: number | undefined;
122
139
  selection: WorkflowRunsSearch | undefined;
123
140
  tab: WorkflowRunTab | undefined;
124
- annotationSummary: RunAnnotationSummary | undefined;
125
141
  activeJobId: string | undefined;
126
142
  jobSearch: WorkflowJobSearch | undefined;
127
143
  jobContent: ReactNode | undefined;
128
144
  }) {
129
145
  const navigate = useNavigate();
130
146
  const runData = query.data;
147
+ const annotationSummary = annotations.summary;
131
148
  const activeSection = runWorkspaceSection(tab);
132
149
  const cancelMutation = useCancelWorkflowRunMutation(runData);
133
150
  const sourceSnapshot = runData?.sourceSnapshot ?? null;
@@ -228,6 +245,24 @@ function RunViewContent({
228
245
  });
229
246
  }
230
247
 
248
+ function clearAnnotationFilters() {
249
+ if (!runData || !workspaceSlug || !projectSlug) return;
250
+ const nextSearch: WorkflowRunsSearch = {...selection, tab: 'annotations'};
251
+ delete nextSearch.jobId;
252
+ delete nextSearch.jobExecutionId;
253
+ delete nextSearch.stepId;
254
+ delete nextSearch.stepAttemptId;
255
+ delete nextSearch.severity;
256
+ delete nextSearch.annotation;
257
+
258
+ void navigate({
259
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
260
+ params: {workspaceSlug, projectSlug, workflowRunId: runData.id},
261
+ search: workflowRunSearchParams(nextSearch, nextSearch) as never,
262
+ replace: true,
263
+ });
264
+ }
265
+
231
266
  function cancelRun() {
232
267
  cancelMutation.mutate(undefined, {
233
268
  onError: (error) => toast.error(cancelErrorMessage(error)),
@@ -291,6 +326,7 @@ function RunViewContent({
291
326
  <RunSectionContent
292
327
  section={activeSection}
293
328
  run={runData}
329
+ annotations={annotations}
294
330
  annotationSummary={annotationSummary}
295
331
  workspaceSlug={workspaceSlug}
296
332
  projectSlug={projectSlug}
@@ -298,6 +334,7 @@ function RunViewContent({
298
334
  selectedJobId={selection?.jobId}
299
335
  onSelectGraphJob={selectGraphJob}
300
336
  onSelectAnnotationJob={selectAnnotationJob}
337
+ onClearAnnotationFilters={clearAnnotationFilters}
301
338
  sourceSnapshot={sourceSnapshot}
302
339
  highlightedLineRange={highlightedLineRange}
303
340
  />
@@ -313,6 +350,7 @@ function RunViewContent({
313
350
  function RunSectionContent({
314
351
  section,
315
352
  run,
353
+ annotations,
316
354
  annotationSummary,
317
355
  workspaceSlug,
318
356
  projectSlug,
@@ -320,11 +358,13 @@ function RunSectionContent({
320
358
  selectedJobId,
321
359
  onSelectGraphJob,
322
360
  onSelectAnnotationJob,
361
+ onClearAnnotationFilters,
323
362
  sourceSnapshot,
324
363
  highlightedLineRange,
325
364
  }: {
326
365
  section: RunWorkspaceSection;
327
366
  run: NonNullable<ReturnType<typeof useWorkflowRunAttemptQuery>['data']>;
367
+ annotations: ReturnType<typeof useRunAnnotationsQuery>;
328
368
  annotationSummary: RunAnnotationSummary | undefined;
329
369
  workspaceSlug: string | undefined;
330
370
  projectSlug: string | undefined;
@@ -332,6 +372,7 @@ function RunSectionContent({
332
372
  selectedJobId: string | undefined;
333
373
  onSelectGraphJob: (jobId: string | undefined, source?: JobGraphSelectionSource) => void;
334
374
  onSelectAnnotationJob: (jobId: string | undefined) => void;
375
+ onClearAnnotationFilters: () => void;
335
376
  sourceSnapshot: NonNullable<
336
377
  ReturnType<typeof useWorkflowRunAttemptQuery>['data']
337
378
  >['sourceSnapshot'];
@@ -343,6 +384,9 @@ function RunSectionContent({
343
384
  return (
344
385
  <section aria-label="All jobs summary" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
345
386
  <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
387
+ <Text as="h2" className="sr-only">
388
+ All jobs summary
389
+ </Text>
346
390
  <JobGraph
347
391
  run={run}
348
392
  selectedJobId={selectedJobId}
@@ -355,33 +399,27 @@ function RunSectionContent({
355
399
  }
356
400
 
357
401
  if (section === 'annotations') {
358
- const selectedJob = run.jobs.find((job) => job.id === selectedJobId);
359
402
  return (
360
- <section aria-label="Run annotations" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
361
- <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
362
- <div className="flex flex-wrap items-center justify-between gap-8">
363
- <RunAnnotationSummaryLine
364
- summary={annotationSummary}
365
- workspaceSlug={workspaceSlug}
366
- projectSlug={projectSlug}
367
- workflowRunId={run.id}
368
- search={selection}
369
- />
370
- <AnnotationJobFilter
371
- jobs={run.jobs}
372
- selectedJobId={selectedJob?.id}
373
- onSelect={onSelectAnnotationJob}
374
- />
375
- </div>
376
- <RunAnnotationsEmpty jobName={selectedJob?.displayName} />
377
- </div>
378
- </section>
403
+ <RunAnnotationsSection
404
+ run={run}
405
+ annotations={annotations}
406
+ annotationSummary={annotationSummary}
407
+ workspaceSlug={workspaceSlug}
408
+ projectSlug={projectSlug}
409
+ selection={selection}
410
+ selectedJobId={selectedJobId}
411
+ onSelectAnnotationJob={onSelectAnnotationJob}
412
+ onClearAnnotationFilters={onClearAnnotationFilters}
413
+ />
379
414
  );
380
415
  }
381
416
 
382
417
  return (
383
418
  <section aria-label="Workflow source" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
384
- <div className="mx-auto flex min-h-full w-full max-w-[1120px] px-24">
419
+ <div className="mx-auto flex min-h-full w-full max-w-[1120px] flex-col px-24">
420
+ <Text as="h2" className="sr-only">
421
+ Workflow source
422
+ </Text>
385
423
  {sourceSnapshot ? (
386
424
  <WorkflowSourceContent
387
425
  source={sourceSnapshot}
@@ -405,6 +443,86 @@ function RunSectionContent({
405
443
  );
406
444
  }
407
445
 
446
+ function RunAnnotationsSection({
447
+ run,
448
+ annotations,
449
+ annotationSummary,
450
+ workspaceSlug,
451
+ projectSlug,
452
+ selection,
453
+ selectedJobId,
454
+ onSelectAnnotationJob,
455
+ onClearAnnotationFilters,
456
+ }: {
457
+ run: NonNullable<ReturnType<typeof useWorkflowRunAttemptQuery>['data']>;
458
+ annotations: ReturnType<typeof useRunAnnotationsQuery>;
459
+ annotationSummary: RunAnnotationSummary | undefined;
460
+ workspaceSlug: string | undefined;
461
+ projectSlug: string | undefined;
462
+ selection: WorkflowRunsSearch | undefined;
463
+ selectedJobId: string | undefined;
464
+ onSelectAnnotationJob: (jobId: string | undefined) => void;
465
+ onClearAnnotationFilters: () => void;
466
+ }) {
467
+ const selectedJob = run.jobs.find((job) => job.id === selectedJobId);
468
+ const severity = selection?.severity;
469
+ const records = annotations.annotations;
470
+
471
+ const entries = useMemo(
472
+ () =>
473
+ records
474
+ ? buildRunAnnotationList({
475
+ annotations: records,
476
+ jobs: run.jobs,
477
+ severity,
478
+ jobId: selectedJob?.id,
479
+ })
480
+ : undefined,
481
+ [records, run.jobs, selectedJob?.id, severity],
482
+ );
483
+
484
+ return (
485
+ <section aria-label="Run annotations" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
486
+ <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
487
+ <Text as="h2" className="sr-only">
488
+ Annotations
489
+ </Text>
490
+ <div className="flex flex-wrap items-center justify-between gap-8">
491
+ <RunAnnotationSummaryLine
492
+ summary={annotationSummary}
493
+ workspaceSlug={workspaceSlug}
494
+ projectSlug={projectSlug}
495
+ workflowRunId={run.id}
496
+ search={selection}
497
+ />
498
+ <AnnotationJobFilter
499
+ jobs={run.jobs}
500
+ selectedJobId={selectedJob?.id}
501
+ onSelect={onSelectAnnotationJob}
502
+ />
503
+ </div>
504
+ <RunAnnotationList
505
+ // Remounting on a filter or run-attempt change resets the render window, so the next
506
+ // list never inherits a "show more" position from different data.
507
+ key={`${run.id}:${run.runAttempt.attempt}:${severity ?? 'all'}:${selectedJob?.id ?? 'all'}`}
508
+ query={annotations.query}
509
+ entries={entries}
510
+ workspaceSlug={workspaceSlug}
511
+ projectSlug={projectSlug}
512
+ workflowRunId={run.id}
513
+ runAttempt={run.runAttempt.attempt}
514
+ // A run with no annotations at all offers no filter to clear, whatever the URL says.
515
+ filtered={Boolean((severity || selectedJob) && (annotationSummary?.total ?? 0) > 0)}
516
+ filteredJobName={selectedJob?.displayName}
517
+ filteredSeverity={severity}
518
+ onClearFilters={onClearAnnotationFilters}
519
+ selectedAnnotationId={selection?.annotation}
520
+ />
521
+ </div>
522
+ </section>
523
+ );
524
+ }
525
+
408
526
  const ALL_ANNOTATION_JOBS = 'all-jobs';
409
527
 
410
528
  function AnnotationJobFilter({
@@ -0,0 +1,315 @@
1
+ import {
2
+ workflowJob,
3
+ workflowJobExecutionDto,
4
+ workflowStepAttemptDto,
5
+ workflowStepDto,
6
+ } from '#test/fixtures/workflow-run.js';
7
+ import {
8
+ buildRunAnnotationList,
9
+ highestRunAnnotationSeverity,
10
+ type RunAnnotationRecord,
11
+ type RunAnnotationStyle,
12
+ summarizeJobAnnotations,
13
+ summarizeRunAnnotations,
14
+ } from './run-annotation.js';
15
+ import type {Job} from './workflow-run.js';
16
+
17
+ const BUILD_JOB_ID = '44444444-4444-4444-8444-00000000000b';
18
+ const TEST_JOB_ID = '44444444-4444-4444-8444-00000000000c';
19
+ const BUILD_EXECUTION_ID = '77777777-7777-4777-8777-00000000000b';
20
+ const TEST_EXECUTION_ONE_ID = '77777777-7777-4777-8777-00000000000c';
21
+ const TEST_EXECUTION_TWO_ID = '77777777-7777-4777-8777-00000000000d';
22
+ const BUILD_STEP_ID = '55555555-5555-4555-8555-00000000000b';
23
+ const TEST_STEP_ID = '55555555-5555-4555-8555-00000000000c';
24
+ const BUILD_ATTEMPT_ID = '66666666-6666-4666-8666-00000000000b';
25
+
26
+ describe('summarizeRunAnnotations', () => {
27
+ test('counts every annotation in the total and only severities per severity', () => {
28
+ const annotations = [
29
+ annotation({id: 'a', style: 'error'}),
30
+ annotation({id: 'b', style: 'warning'}),
31
+ annotation({id: 'c', style: 'default'}),
32
+ annotation({id: 'd', style: 'error'}),
33
+ ];
34
+
35
+ const summary = summarizeRunAnnotations(annotations);
36
+
37
+ expect(summary).toEqual({
38
+ total: 4,
39
+ error: 2,
40
+ warning: 1,
41
+ info: 0,
42
+ success: 0,
43
+ truncated: false,
44
+ });
45
+ });
46
+
47
+ test('carries truncation so counts read as a lower bound', () => {
48
+ const summary = summarizeRunAnnotations([annotation({id: 'a', style: 'info'})], {
49
+ truncated: true,
50
+ });
51
+
52
+ expect(summary.truncated).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('summarizeJobAnnotations', () => {
57
+ test('counts only the annotations owned by one job', () => {
58
+ const annotations = [
59
+ annotation({id: 'a', style: 'error', jobId: BUILD_JOB_ID}),
60
+ annotation({id: 'b', style: 'warning', jobId: TEST_JOB_ID}),
61
+ annotation({id: 'c', style: 'info', jobId: TEST_JOB_ID}),
62
+ ];
63
+
64
+ const summary = summarizeJobAnnotations(annotations, TEST_JOB_ID);
65
+
66
+ expect(summary.total).toBe(2);
67
+ expect(summary.error).toBe(0);
68
+ expect(summary.warning).toBe(1);
69
+ });
70
+ });
71
+
72
+ describe('highestRunAnnotationSeverity', () => {
73
+ test('reports the loudest severity present', () => {
74
+ const summary = summarizeRunAnnotations([
75
+ annotation({id: 'a', style: 'success'}),
76
+ annotation({id: 'b', style: 'warning'}),
77
+ ]);
78
+
79
+ expect(highestRunAnnotationSeverity(summary)).toBe('warning');
80
+ });
81
+
82
+ test('reports nothing when only unstyled annotations exist', () => {
83
+ const summary = summarizeRunAnnotations([annotation({id: 'a', style: 'default'})]);
84
+
85
+ expect(highestRunAnnotationSeverity(summary)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('buildRunAnnotationList', () => {
90
+ test('ranks by severity before emission order', () => {
91
+ const annotations = [
92
+ annotation({id: 'a', style: 'success', sequence: 1}),
93
+ annotation({id: 'b', style: 'error', sequence: 2}),
94
+ annotation({id: 'c', style: 'default', sequence: 3}),
95
+ annotation({id: 'd', style: 'warning', sequence: 4}),
96
+ annotation({id: 'e', style: 'info', sequence: 5}),
97
+ ];
98
+
99
+ const entries = buildRunAnnotationList({annotations, jobs: jobs()});
100
+
101
+ expect(entries.map((entry) => entry.annotation.id)).toEqual(['b', 'd', 'e', 'a', 'c']);
102
+ });
103
+
104
+ test('breaks a severity tie by job position before sequence', () => {
105
+ // `sequence` restarts per execution, so the later job's lower sequence must not lead.
106
+ const annotations = [
107
+ annotation({id: 'late-job', style: 'error', sequence: 1, jobId: TEST_JOB_ID}),
108
+ annotation({id: 'early-job', style: 'error', sequence: 9, jobId: BUILD_JOB_ID}),
109
+ ];
110
+
111
+ const entries = buildRunAnnotationList({annotations, jobs: jobs()});
112
+
113
+ expect(entries.map((entry) => entry.annotation.id)).toEqual(['early-job', 'late-job']);
114
+ });
115
+
116
+ test('breaks a job tie by execution sequence', () => {
117
+ const annotations = [
118
+ annotation({
119
+ id: 'second-execution',
120
+ style: 'error',
121
+ sequence: 1,
122
+ jobId: TEST_JOB_ID,
123
+ jobExecutionId: TEST_EXECUTION_TWO_ID,
124
+ }),
125
+ annotation({
126
+ id: 'first-execution',
127
+ style: 'error',
128
+ sequence: 2,
129
+ jobId: TEST_JOB_ID,
130
+ jobExecutionId: TEST_EXECUTION_ONE_ID,
131
+ }),
132
+ ];
133
+
134
+ const entries = buildRunAnnotationList({annotations, jobs: jobs()});
135
+
136
+ expect(entries.map((entry) => entry.annotation.id)).toEqual([
137
+ 'first-execution',
138
+ 'second-execution',
139
+ ]);
140
+ });
141
+
142
+ test('resolves provenance and an origin the run can route back to', () => {
143
+ const annotations = [annotation({id: 'a', style: 'error'})];
144
+
145
+ const [entry] = buildRunAnnotationList({annotations, jobs: jobs()});
146
+
147
+ expect(entry?.jobName).toBe('build');
148
+ expect(entry?.stepLabel).toBe('compile');
149
+ expect(entry?.attemptLabel).toBe('attempt 1');
150
+ expect(entry?.origin).toEqual({
151
+ jobId: BUILD_JOB_ID,
152
+ jobExecutionId: BUILD_EXECUTION_ID,
153
+ stepId: BUILD_STEP_ID,
154
+ stepAttemptId: BUILD_ATTEMPT_ID,
155
+ });
156
+ });
157
+
158
+ test('names an execution only when the job ran more than once', () => {
159
+ const annotations = [
160
+ annotation({id: 'single', style: 'info'}),
161
+ annotation({
162
+ id: 'multi',
163
+ style: 'info',
164
+ jobId: TEST_JOB_ID,
165
+ jobExecutionId: TEST_EXECUTION_TWO_ID,
166
+ originStepId: TEST_STEP_ID,
167
+ }),
168
+ ];
169
+
170
+ const entries = buildRunAnnotationList({annotations, jobs: jobs()});
171
+ const byId = new Map(entries.map((entry) => [entry.annotation.id, entry]));
172
+
173
+ expect(byId.get('single')?.executionLabel).toBeNull();
174
+ expect(byId.get('multi')?.executionLabel).toBe('execution #2');
175
+ });
176
+
177
+ test('keeps an annotation whose step the run attempt no longer holds, without an origin', () => {
178
+ const annotations = [
179
+ annotation({
180
+ id: 'orphan',
181
+ style: 'error',
182
+ originStepId: '55555555-5555-4555-8555-0000000000ff',
183
+ }),
184
+ ];
185
+
186
+ const [entry] = buildRunAnnotationList({annotations, jobs: jobs()});
187
+
188
+ expect(entry?.annotation.id).toBe('orphan');
189
+ expect(entry?.jobName).toBe('build');
190
+ expect(entry?.stepLabel).toBeNull();
191
+ expect(entry?.origin).toBeNull();
192
+ });
193
+
194
+ test('sorts an annotation from an unknown job last rather than first', () => {
195
+ const annotations = [
196
+ annotation({
197
+ id: 'unknown-job',
198
+ style: 'error',
199
+ jobId: '44444444-4444-4444-8444-0000000000ff',
200
+ }),
201
+ annotation({id: 'known-job', style: 'error'}),
202
+ ];
203
+
204
+ const entries = buildRunAnnotationList({annotations, jobs: jobs()});
205
+
206
+ expect(entries.map((entry) => entry.annotation.id)).toEqual(['known-job', 'unknown-job']);
207
+ });
208
+
209
+ test('filters by severity and by job', () => {
210
+ const annotations = [
211
+ annotation({id: 'build-error', style: 'error'}),
212
+ annotation({id: 'build-info', style: 'info'}),
213
+ annotation({id: 'test-error', style: 'error', jobId: TEST_JOB_ID}),
214
+ ];
215
+
216
+ expect(
217
+ buildRunAnnotationList({annotations, jobs: jobs(), severity: 'error'}).map(
218
+ (entry) => entry.annotation.id,
219
+ ),
220
+ ).toEqual(['build-error', 'test-error']);
221
+ expect(
222
+ buildRunAnnotationList({annotations, jobs: jobs(), jobId: BUILD_JOB_ID}).map(
223
+ (entry) => entry.annotation.id,
224
+ ),
225
+ ).toEqual(['build-error', 'build-info']);
226
+ });
227
+
228
+ test('never selects an unstyled annotation with a severity filter', () => {
229
+ const annotations = [annotation({id: 'plain', style: 'default'})];
230
+
231
+ expect(buildRunAnnotationList({annotations, jobs: jobs(), severity: 'info'})).toEqual([]);
232
+ });
233
+ });
234
+
235
+ function annotation(
236
+ overrides: Partial<RunAnnotationRecord> & {id: string; style: RunAnnotationStyle},
237
+ ): RunAnnotationRecord {
238
+ return {
239
+ jobId: BUILD_JOB_ID,
240
+ jobExecutionId: BUILD_EXECUTION_ID,
241
+ originStepId: BUILD_STEP_ID,
242
+ originStepAttempt: 1,
243
+ context: `context-${overrides.id}`,
244
+ sequence: 1,
245
+ body: 'Body',
246
+ ...overrides,
247
+ };
248
+ }
249
+
250
+ function jobs(): Job[] {
251
+ return [
252
+ workflowJob({
253
+ id: BUILD_JOB_ID,
254
+ key: 'build',
255
+ name: 'build',
256
+ position: 0,
257
+ job_executions: [
258
+ workflowJobExecutionDto({
259
+ id: BUILD_EXECUTION_ID,
260
+ job_id: BUILD_JOB_ID,
261
+ sequence: 1,
262
+ steps: [
263
+ workflowStepDto({
264
+ id: BUILD_STEP_ID,
265
+ job_execution_id: BUILD_EXECUTION_ID,
266
+ name: 'compile',
267
+ attempts: [
268
+ workflowStepAttemptDto({
269
+ id: BUILD_ATTEMPT_ID,
270
+ step_id: BUILD_STEP_ID,
271
+ attempt: 1,
272
+ status: 'succeeded',
273
+ }),
274
+ ],
275
+ }),
276
+ ],
277
+ }),
278
+ ],
279
+ }),
280
+ workflowJob({
281
+ id: TEST_JOB_ID,
282
+ key: 'test',
283
+ name: 'test',
284
+ position: 1,
285
+ job_executions: [
286
+ workflowJobExecutionDto({
287
+ id: TEST_EXECUTION_ONE_ID,
288
+ job_id: TEST_JOB_ID,
289
+ sequence: 1,
290
+ steps: [
291
+ workflowStepDto({
292
+ id: TEST_STEP_ID,
293
+ job_execution_id: TEST_EXECUTION_ONE_ID,
294
+ name: 'run tests',
295
+ attempts: [],
296
+ }),
297
+ ],
298
+ }),
299
+ workflowJobExecutionDto({
300
+ id: TEST_EXECUTION_TWO_ID,
301
+ job_id: TEST_JOB_ID,
302
+ sequence: 2,
303
+ steps: [
304
+ workflowStepDto({
305
+ id: TEST_STEP_ID,
306
+ job_execution_id: TEST_EXECUTION_TWO_ID,
307
+ name: 'run tests',
308
+ attempts: [],
309
+ }),
310
+ ],
311
+ }),
312
+ ],
313
+ }),
314
+ ];
315
+ }