@cat-factory/app 0.227.0 → 0.228.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.
@@ -82,10 +82,18 @@ export function useRunDeepLink(): void {
82
82
  if (!ready || applied) return
83
83
  applied = true
84
84
  if (link.blockId) ui.select(link.blockId)
85
- // Two views are served: the observability panel and the Tester's result window (where the
86
- // screenshots the report's environment-lifecycle section lists are rendered). An unknown
87
- // view still lands the user on the right board and task rather than failing the navigation.
88
- if (link.view === 'observability') ui.openObservability(link.runId)
85
+ // Three views are served: the run's outcome summary (the non-code answer to "what did this
86
+ // change"), the observability panel, and the Tester's result window (where the screenshots
87
+ // the report's environment-lifecycle section lists are rendered). An unknown view still
88
+ // lands the user on the right board and task rather than failing the navigation.
89
+ //
90
+ // `outcome` is passed the block as well as the run: it is the one of the three that stays
91
+ // readable on a task whose run the snapshot no longer carries, and a link followed weeks
92
+ // after the work merged is exactly that case. The engine emits no `view=outcome` link
93
+ // today (its report links a REVIEWER to the two run-scoped panels); this is the entry
94
+ // point for one, and for a URL a person shares.
95
+ if (link.view === 'outcome') ui.openRunOutcome(link.runId, link.blockId)
96
+ else if (link.view === 'observability') ui.openObservability(link.runId)
89
97
  else if (link.view === 'test-evidence') ui.openTestEvidence(link.runId)
90
98
  stop?.()
91
99
  },
@@ -1,6 +1,7 @@
1
1
  import type { Component } from 'vue'
2
2
  import { defineModule } from '@modular-vue/core'
3
3
  import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
4
+ import OutcomeSummaryWindow from '~/components/outcome/OutcomeSummaryWindow.vue'
4
5
  import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
5
6
  import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
6
7
  import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
@@ -51,6 +52,9 @@ import type { ResultViewContribution } from './slots'
51
52
  * that could ship. Consumer namespaced ids are validated separately by `pairById`.
52
53
  */
53
54
  const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
55
+ // The run's non-code outcome summary: what changed in product terms, with the captured
56
+ // evidence, and the diff one click away. RUN-keyed (no step), opened by `ui.openOutcome`.
57
+ outcome: OutcomeSummaryWindow,
54
58
  'requirements-review': RequirementsReviewWindow,
55
59
  'clarity-review': ClarityReviewWindow,
56
60
  // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
@@ -113,6 +113,32 @@ export function createUiResultViews() {
113
113
  stepDetail.value = { instanceId, stepIndex }
114
114
  }
115
115
 
116
+ /**
117
+ * Open a task's NON-CODE OUTCOME summary — what the run changed in product terms, with the
118
+ * evidence behind it, and the diff one click away. BLOCK-keyed rather than run-keyed because a
119
+ * merged task keeps its pull request long after its run instance is gone, and that task is
120
+ * exactly the one somebody comes back to read. The run rides along when there is one, which is
121
+ * where every piece of evidence comes from; `stepIndex` stays null because the summary is
122
+ * composed from the WHOLE run and there is no step for it to be about.
123
+ */
124
+ function openOutcome(blockId: string, instanceId: string | null = null) {
125
+ resultView.value = { view: 'outcome', blockId, instanceId, stepIndex: null }
126
+ }
127
+
128
+ /**
129
+ * Open the outcome summary from a caller that knows the RUN: the `outcome` deep link.
130
+ *
131
+ * The run id is a LOOKUP here, not the key — the window is block-keyed (above) — so a link
132
+ * naming its block opens on it even when the store never hydrated that run, which is the
133
+ * normal state of following a link into a task that finished long ago. Falling back to the
134
+ * run's own `blockId` keeps a link that carries only `run=` working. Only a link that
135
+ * resolves neither is a silent no-op, matching the run-step openers.
136
+ */
137
+ function openRunOutcome(instanceId: string, blockId: string | null = null) {
138
+ const resolved = blockId ?? useExecutionStore().getInstance(instanceId)?.blockId ?? null
139
+ if (resolved) openOutcome(resolved, instanceId)
140
+ }
141
+
116
142
  function openRequirementReview(blockId: string) {
117
143
  resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
118
144
  }
@@ -185,6 +211,8 @@ export function createUiResultViews() {
185
211
  openForkDecision,
186
212
  openPrReview,
187
213
  openTestEvidence,
214
+ openOutcome,
215
+ openRunOutcome,
188
216
  closeResultView,
189
217
  closeRequirementReview,
190
218
  openStepDetail,
@@ -134,4 +134,59 @@ describe('dispatchStepView routing', () => {
134
134
  expect(ui.stepDetail).toBeNull()
135
135
  })
136
136
  })
137
+
138
+ // The outcome summary is the RUN's, not a step's, and it has two entry points: the board and
139
+ // inspector know the block (and a merged task's run may be gone), while a deep link knows only
140
+ // the run id.
141
+ describe('openOutcome', () => {
142
+ it('opens block-keyed with no step, and with no run when the task has none', () => {
143
+ ui.openOutcome('b1')
144
+
145
+ expect(ui.resultView).toEqual({
146
+ view: 'outcome',
147
+ blockId: 'b1',
148
+ instanceId: null,
149
+ stepIndex: null,
150
+ })
151
+ })
152
+
153
+ it('carries the run when the caller knows it', () => {
154
+ ui.openOutcome('b1', 'e1')
155
+
156
+ expect(ui.resultView).toMatchObject({ view: 'outcome', blockId: 'b1', instanceId: 'e1' })
157
+ })
158
+
159
+ it('resolves the block from the run for a run-only caller (the deep link)', () => {
160
+ execution.hydrate([instance('e1', 'b1', [{ agentKind: 'coder' }])], 'ws1')
161
+
162
+ ui.openRunOutcome('e1')
163
+
164
+ expect(ui.resultView).toEqual({
165
+ view: 'outcome',
166
+ blockId: 'b1',
167
+ instanceId: 'e1',
168
+ stepIndex: null,
169
+ })
170
+ })
171
+
172
+ // The link the deep-link consumer passes carries BOTH ids, and the run is only a lookup:
173
+ // following one into a task that finished long ago is the normal case, and the snapshot
174
+ // that hydrates the board is not obliged to still carry that run.
175
+ it('opens on the block the link names even when the run was never hydrated', () => {
176
+ ui.openRunOutcome('missing', 'b1')
177
+
178
+ expect(ui.resultView).toEqual({
179
+ view: 'outcome',
180
+ blockId: 'b1',
181
+ instanceId: 'missing',
182
+ stepIndex: null,
183
+ })
184
+ })
185
+
186
+ it('does nothing for a run the store has not hydrated and a link with no block', () => {
187
+ ui.openRunOutcome('missing')
188
+
189
+ expect(ui.resultView).toBeNull()
190
+ })
191
+ })
137
192
  })
@@ -0,0 +1,492 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Block } from '~/types/domain'
3
+ import type { ExecutionInstance, PipelineStep } from '~/types/execution'
4
+ import type { ServiceSpecView } from '~/types/spec'
5
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
6
+
7
+ // The composer's whole job is to keep facts that mean different things from rendering the same,
8
+ // so the cases worth pinning are the COLLAPSES: an absent producer vs a producer that found
9
+ // nothing, an aspirational failure vs a regression, a capture nobody reviewed vs a pair a human
10
+ // approved, and a tester that could not run vs one that ran and raised concerns.
11
+
12
+ function block(overrides: Partial<Block> = {}): Block {
13
+ return {
14
+ id: 'blk_1',
15
+ title: 'Password reset',
16
+ description: ' Let a signed-out user reset their password by email. ',
17
+ status: 'pr_ready',
18
+ level: 'task',
19
+ type: 'task',
20
+ parentId: 'frm_1',
21
+ position: { x: 0, y: 0 },
22
+ progress: 0,
23
+ dependsOn: [],
24
+ taskType: 'feature',
25
+ ...overrides,
26
+ } as Block
27
+ }
28
+
29
+ function step(overrides: Partial<PipelineStep> = {}): PipelineStep {
30
+ return {
31
+ agentKind: 'coder',
32
+ state: 'done',
33
+ progress: 1,
34
+ decision: null,
35
+ ...overrides,
36
+ } as PipelineStep
37
+ }
38
+
39
+ function run(steps: PipelineStep[], overrides: Partial<ExecutionInstance> = {}): ExecutionInstance {
40
+ return {
41
+ id: 'exe_1',
42
+ blockId: 'blk_1',
43
+ pipelineId: 'pl_1',
44
+ pipelineName: 'Build',
45
+ steps,
46
+ currentStep: 0,
47
+ status: 'running',
48
+ ...overrides,
49
+ } as ExecutionInstance
50
+ }
51
+
52
+ function testerStep(report: Record<string, unknown>, kind = 'tester-ui'): PipelineStep {
53
+ return step({
54
+ agentKind: kind,
55
+ test: {
56
+ phase: 'testing',
57
+ attempts: 0,
58
+ maxAttempts: 3,
59
+ lastReport: {
60
+ greenlight: true,
61
+ summary: 'Exercised the reset flow end to end.',
62
+ tested: ['Password reset'],
63
+ outcomes: [],
64
+ concerns: [],
65
+ ...report,
66
+ },
67
+ },
68
+ } as Partial<PipelineStep>)
69
+ }
70
+
71
+ const spec: ServiceSpecView = {
72
+ present: true,
73
+ spec: {
74
+ service: 'accounts',
75
+ summary: '',
76
+ modules: [
77
+ {
78
+ name: 'auth',
79
+ summary: '',
80
+ groups: [
81
+ {
82
+ name: 'login',
83
+ summary: '',
84
+ rules: [],
85
+ requirements: [
86
+ {
87
+ id: 'req-reset',
88
+ title: 'A user can reset their password',
89
+ statement: 'The system SHALL send a reset link.',
90
+ kind: 'functional',
91
+ priority: 'must',
92
+ state: 'aspirational',
93
+ acceptance: [],
94
+ sourceBlockIds: [],
95
+ },
96
+ {
97
+ id: 'req-login',
98
+ title: 'A user can sign in',
99
+ statement: 'The system SHALL authenticate a user.',
100
+ kind: 'functional',
101
+ priority: 'must',
102
+ state: 'established',
103
+ acceptance: [],
104
+ sourceBlockIds: [],
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ features: [],
113
+ }
114
+
115
+ describe('composeRunOutcome', () => {
116
+ it('carries the ask and every pull request so the diff stays one click away', () => {
117
+ const outcome = composeRunOutcome({
118
+ block: block({
119
+ pullRequest: { url: 'https://host/pr/7', number: 7, branch: 'cat-factory/blk_1' },
120
+ peerPullRequests: [{ repo: 'acme/api', ref: { url: 'https://host/api/pr/3', number: 3 } }],
121
+ }),
122
+ instance: null,
123
+ })
124
+
125
+ expect(outcome.title).toBe('Password reset')
126
+ expect(outcome.ask).toBe('Let a signed-out user reset their password by email.')
127
+ expect(outcome.disposition).toBe('awaiting_merge')
128
+ expect(outcome.pullRequests).toEqual([
129
+ { url: 'https://host/pr/7', number: 7, branch: 'cat-factory/blk_1', repo: null },
130
+ { url: 'https://host/api/pr/3', number: 3, branch: null, repo: 'acme/api' },
131
+ ])
132
+ })
133
+
134
+ it('distinguishes no tester step from a tester that has not reported', () => {
135
+ const none = composeRunOutcome({ block: block(), instance: run([step()]) })
136
+ expect(none.tests).toEqual({ status: 'absent', gap: 'no_tester_step' })
137
+ expect(none.requirements).toEqual({ status: 'absent', gap: 'no_tester_step' })
138
+
139
+ const silent = composeRunOutcome({
140
+ block: block(),
141
+ instance: run([step({ agentKind: 'tester-api' })]),
142
+ })
143
+ expect(silent.tests).toEqual({ status: 'absent', gap: 'tester_not_reported' })
144
+ })
145
+
146
+ it('keeps a tester that could not run apart from one that raised concerns', () => {
147
+ const aborted = composeRunOutcome({
148
+ block: block(),
149
+ instance: run([
150
+ testerStep({
151
+ greenlight: false,
152
+ abort: { reason: 'The preview environment never came up.' },
153
+ }),
154
+ ]),
155
+ })
156
+ expect(aborted.tests).toMatchObject({
157
+ status: 'reported',
158
+ verdict: 'could_not_run',
159
+ abortReason: 'The preview environment never came up.',
160
+ concerns: [],
161
+ })
162
+
163
+ const buggy = composeRunOutcome({
164
+ block: block(),
165
+ instance: run([
166
+ testerStep({
167
+ greenlight: false,
168
+ concerns: [{ title: 'Reset link expires immediately', detail: '…', severity: 'high' }],
169
+ outcomes: [
170
+ { name: 'Reset', status: 'failed' },
171
+ { name: 'Login', status: 'passed' },
172
+ { name: 'Rate limit', status: 'skipped' },
173
+ ],
174
+ }),
175
+ ]),
176
+ })
177
+ expect(buggy.tests).toMatchObject({
178
+ verdict: 'concerns',
179
+ passed: 1,
180
+ failed: 1,
181
+ skipped: 1,
182
+ concerns: [{ title: 'Reset link expires immediately', severity: 'high' }],
183
+ })
184
+ })
185
+
186
+ it('reports a failing ESTABLISHED requirement as a regression and an aspirational one as not', () => {
187
+ const outcome = composeRunOutcome({
188
+ block: block(),
189
+ instance: run([
190
+ testerStep({
191
+ requirementVerdicts: [
192
+ { requirementId: 'req-reset', status: 'not_met', detail: 'Not built yet.' },
193
+ { requirementId: 'req-login', status: 'not_met', detail: 'Sign-in now 500s.' },
194
+ ],
195
+ }),
196
+ ]),
197
+ spec,
198
+ })
199
+
200
+ expect(outcome.requirements).toMatchObject({
201
+ status: 'reported',
202
+ spec: 'joined',
203
+ regressions: 1,
204
+ })
205
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
206
+ // The regression leads, and both are still counted as failures.
207
+ expect(outcome.requirements.entries.map((e) => [e.id, e.regression])).toEqual([
208
+ ['req-login', true],
209
+ ['req-reset', false],
210
+ ])
211
+ expect(outcome.requirements.notMet).toBe(2)
212
+ expect(outcome.requirements.entries[0]?.title).toBe('A user can sign in')
213
+ })
214
+
215
+ it('says the spec was never read rather than rendering ids as titles', () => {
216
+ const outcome = composeRunOutcome({
217
+ block: block(),
218
+ instance: run([
219
+ testerStep({ requirementVerdicts: [{ requirementId: 'req-reset', status: 'met' }] }),
220
+ ]),
221
+ })
222
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'not_read' })
223
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
224
+ expect(outcome.requirements.entries[0]).toMatchObject({
225
+ id: 'req-reset',
226
+ title: null,
227
+ state: null,
228
+ regression: false,
229
+ })
230
+ })
231
+
232
+ // A spec that WAS read and names none of the reported ids leaves identical rows behind, and
233
+ // sends the reader to a different fix: the spec moved on, or the tester keyed its verdicts by
234
+ // something else. Reporting it as a failed read would send them to fix a read that worked.
235
+ it('keeps a spec that was never read apart from one that named none of these ids', () => {
236
+ const outcome = composeRunOutcome({
237
+ block: block(),
238
+ instance: run([
239
+ testerStep({ requirementVerdicts: [{ requirementId: 'req-gone', status: 'met' }] }),
240
+ ]),
241
+ spec,
242
+ })
243
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'unmatched' })
244
+ })
245
+
246
+ // The partial join is the case a section-level note cannot state: some rows carry titles, and
247
+ // an id sitting unmarked between them reads as a requirement named after a slug.
248
+ it('marks the rows the spec did not name while the section as a whole joined', () => {
249
+ const outcome = composeRunOutcome({
250
+ block: block(),
251
+ instance: run([
252
+ testerStep({
253
+ requirementVerdicts: [
254
+ { requirementId: 'req-login', status: 'met' },
255
+ { requirementId: 'req-gone', status: 'met' },
256
+ ],
257
+ }),
258
+ ]),
259
+ spec,
260
+ })
261
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'joined' })
262
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
263
+ const rows = outcome.requirements.entries
264
+ expect(rows.find((e) => e.id === 'req-login')?.title).toBe('A user can sign in')
265
+ expect(rows.find((e) => e.id === 'req-gone')?.title).toBeNull()
266
+ })
267
+
268
+ it('separates a tester report with no verdicts from a tester that never reported', () => {
269
+ const outcome = composeRunOutcome({
270
+ block: block(),
271
+ instance: run([testerStep({ requirementVerdicts: [] })]),
272
+ })
273
+ expect(outcome.requirements).toEqual({ status: 'absent', gap: 'no_verdicts' })
274
+ })
275
+
276
+ it('prefers the reviewed visual-confirmation pairs over the tester’s raw captures', () => {
277
+ const outcome = composeRunOutcome({
278
+ block: block(),
279
+ instance: run([
280
+ testerStep({ screenshots: [{ view: 'reset', artifactId: 'art_shot' }] }),
281
+ step({
282
+ agentKind: 'visual-confirmation',
283
+ visualConfirm: {
284
+ phase: 'approved',
285
+ attempts: 0,
286
+ maxAttempts: 2,
287
+ pairs: [{ view: 'reset', actualArtifactId: 'art_a', referenceArtifactId: 'art_ref' }],
288
+ },
289
+ } as Partial<PipelineStep>),
290
+ ]),
291
+ })
292
+ expect(outcome.visuals).toEqual({
293
+ status: 'reported',
294
+ source: 'visual_confirm',
295
+ phase: 'approved',
296
+ views: [{ view: 'reset', artifactId: 'art_a', referenceArtifactId: 'art_ref' }],
297
+ })
298
+ })
299
+
300
+ it('falls back to the tester’s captures, and says which gap it hit when there are none', () => {
301
+ const captured = composeRunOutcome({
302
+ block: block(),
303
+ instance: run([testerStep({ screenshots: [{ view: 'reset', artifactId: 'art_shot' }] })]),
304
+ })
305
+ expect(captured.visuals).toMatchObject({ status: 'reported', source: 'tester', phase: null })
306
+
307
+ // An API tester captures nothing by design: no producer, so nothing was ever meant to be seen.
308
+ const apiOnly = composeRunOutcome({
309
+ block: block(),
310
+ instance: run([testerStep({}, 'tester-api')]),
311
+ })
312
+ expect(apiOnly.visuals).toEqual({ status: 'absent', gap: 'no_visual_step', detail: null })
313
+
314
+ // A gate that ran and gathered nothing is a different fact, and it recorded why.
315
+ const degraded = composeRunOutcome({
316
+ block: block(),
317
+ instance: run([
318
+ step({
319
+ agentKind: 'visual-confirmation',
320
+ visualConfirm: {
321
+ phase: 'awaiting_human',
322
+ attempts: 0,
323
+ maxAttempts: 2,
324
+ pairs: [],
325
+ degradedReason: 'No artifact storage configured.',
326
+ },
327
+ } as Partial<PipelineStep>),
328
+ ]),
329
+ })
330
+ expect(degraded.visuals).toEqual({
331
+ status: 'absent',
332
+ gap: 'none_captured',
333
+ detail: 'No artifact storage configured.',
334
+ })
335
+ })
336
+
337
+ it('lists only the checks that actually recorded a verdict', () => {
338
+ const outcome = composeRunOutcome({
339
+ block: block(),
340
+ instance: run([
341
+ step({
342
+ agentKind: 'ci',
343
+ gate: { phase: 'checking', attempts: 0, maxAttempts: 3 },
344
+ } as Partial<PipelineStep>),
345
+ step({
346
+ validation: { passed: false, attempts: 2, maxAttempts: 3, outcomes: [] },
347
+ } as Partial<PipelineStep>),
348
+ step({
349
+ reproduction: {
350
+ status: 'inconclusive',
351
+ command: 'pnpm test',
352
+ testPaths: [],
353
+ attempts: 1,
354
+ maxAttempts: 2,
355
+ at: 1,
356
+ },
357
+ } as Partial<PipelineStep>),
358
+ ]),
359
+ })
360
+
361
+ // The CI gate has not probed yet, so it contributes NOTHING rather than a green row.
362
+ expect(outcome.checks).toEqual([
363
+ { kind: 'validation', state: 'fail', reproduction: null },
364
+ { kind: 'reproduction', state: 'inconclusive', reproduction: 'inconclusive' },
365
+ ])
366
+ })
367
+
368
+ it('reads the CI gate’s recorded verdict once it has probed', () => {
369
+ const outcome = composeRunOutcome({
370
+ block: block(),
371
+ instance: run([
372
+ step({
373
+ agentKind: 'ci',
374
+ gate: { phase: 'checking', attempts: 0, maxAttempts: 3, lastVerdict: 'pass' },
375
+ } as Partial<PipelineStep>),
376
+ ]),
377
+ })
378
+ expect(outcome.checks).toEqual([{ kind: 'ci', state: 'pass', reproduction: null }])
379
+ })
380
+
381
+ // A block that NAMES a run the caller could not resolve is the trap this whole module is
382
+ // about: composed from the empty step list it would report a pipeline that ran and produced
383
+ // nothing, which is the opposite of "nobody could read what it produced".
384
+ it('says the run could not be read rather than blaming the pipeline for the missing steps', () => {
385
+ const outcome = composeRunOutcome({
386
+ block: block({ status: 'done', executionId: 'exe_gone' }),
387
+ instance: null,
388
+ })
389
+
390
+ expect(outcome.requirements).toEqual({ status: 'absent', gap: 'run_unavailable' })
391
+ expect(outcome.tests).toEqual({ status: 'absent', gap: 'run_unavailable' })
392
+ expect(outcome.visuals).toEqual({ status: 'absent', gap: 'run_unavailable', detail: null })
393
+ expect(outcome.checks).toEqual([])
394
+ // The block still carries what the block knows.
395
+ expect(outcome.disposition).toBe('merged')
396
+ expect(outcome.title).toBe('Password reset')
397
+ })
398
+
399
+ it('keeps a task that never ran apart from one whose run could not be read', () => {
400
+ const never = composeRunOutcome({ block: block({ status: 'ready' }), instance: null })
401
+ const unread = composeRunOutcome({
402
+ block: block({ status: 'ready', executionId: 'exe_gone' }),
403
+ instance: null,
404
+ })
405
+
406
+ expect(never.disposition).toBe('not_run')
407
+ expect(never.tests).toEqual({ status: 'absent', gap: 'no_tester_step' })
408
+ expect(unread.disposition).toBe('unknown')
409
+ expect(unread.tests).toEqual({ status: 'absent', gap: 'run_unavailable' })
410
+ })
411
+
412
+ // The selected tester is the one that REPORTED, which can be the api half of a pipeline whose
413
+ // ui half has not. Reading the producer off it would tell a reader looking at a UI pipeline
414
+ // that nothing in it captures the interface.
415
+ it('finds the interface producer anywhere in the pipeline, not only on the reporting tester', () => {
416
+ const outcome = composeRunOutcome({
417
+ block: block(),
418
+ instance: run([
419
+ step({ agentKind: 'tester-ui' }),
420
+ testerStep({ screenshots: [] }, 'tester-api'),
421
+ ]),
422
+ })
423
+ expect(outcome.visuals).toEqual({ status: 'absent', gap: 'none_captured', detail: null })
424
+ })
425
+
426
+ it('derives the disposition from the block, and from the run only where the block cannot', () => {
427
+ const dispositions = [
428
+ composeRunOutcome({ block: block({ status: 'done' }), instance: null }).disposition,
429
+ composeRunOutcome({ block: block({ status: 'pr_ready' }), instance: null }).disposition,
430
+ composeRunOutcome({ block: block({ status: 'planned' }), instance: null }).disposition,
431
+ composeRunOutcome({ block: block({ status: 'in_progress' }), instance: run([step()]) })
432
+ .disposition,
433
+ composeRunOutcome({
434
+ block: block({ status: 'in_progress' }),
435
+ instance: run([step()], { status: 'failed' }),
436
+ }).disposition,
437
+ // `in_progress` is the block's own word for a live run, so it stands with no instance.
438
+ composeRunOutcome({
439
+ block: block({ status: 'in_progress', executionId: 'exe_gone' }),
440
+ instance: null,
441
+ }).disposition,
442
+ ]
443
+ expect(dispositions).toEqual([
444
+ 'merged',
445
+ 'awaiting_merge',
446
+ 'not_run',
447
+ 'in_flight',
448
+ 'needs_attention',
449
+ 'in_flight',
450
+ ])
451
+ })
452
+ })
453
+
454
+ describe('hasOutcomeToShow', () => {
455
+ it('is false for a run that has produced nothing to read yet', () => {
456
+ expect(
457
+ hasOutcomeToShow(composeRunOutcome({ block: block({ status: 'planned' }), instance: null })),
458
+ ).toBe(false)
459
+ })
460
+
461
+ // The affordance the board card and the inspector both gate on: a task marked done by hand,
462
+ // carrying no pull request and no readable run, has nothing an outcome card could show.
463
+ it('is false for a task whose run cannot be read and which carries no pull request', () => {
464
+ expect(
465
+ hasOutcomeToShow(
466
+ composeRunOutcome({
467
+ block: block({ status: 'done', executionId: 'exe_gone' }),
468
+ instance: null,
469
+ }),
470
+ ),
471
+ ).toBe(false)
472
+ })
473
+
474
+ it('is true as soon as there is a PR or any recorded evidence', () => {
475
+ expect(
476
+ hasOutcomeToShow(
477
+ composeRunOutcome({
478
+ block: block({ pullRequest: { url: 'https://host/pr/7', number: 7 } }),
479
+ instance: null,
480
+ }),
481
+ ),
482
+ ).toBe(true)
483
+ expect(
484
+ hasOutcomeToShow(
485
+ composeRunOutcome({
486
+ block: block({ status: 'in_progress' }),
487
+ instance: run([testerStep({})]),
488
+ }),
489
+ ),
490
+ ).toBe(true)
491
+ })
492
+ })