@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.
- package/README.md +25 -0
- package/app/components/board/nodes/TaskCard.vue +68 -8
- package/app/components/outcome/OutcomeSummaryWindow.vue +614 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +33 -0
- package/app/composables/useRunDeepLink.ts +12 -4
- package/app/modular/result-views.ts +4 -0
- package/app/stores/ui/resultViews.ts +28 -0
- package/app/stores/ui.dispatch.spec.ts +55 -0
- package/app/utils/runOutcome.spec.ts +492 -0
- package/app/utils/runOutcome.ts +509 -0
- package/i18n/locales/de.json +107 -0
- package/i18n/locales/en.json +107 -0
- package/i18n/locales/es.json +107 -0
- package/i18n/locales/fr.json +107 -0
- package/i18n/locales/he.json +107 -0
- package/i18n/locales/it.json +107 -0
- package/i18n/locales/ja.json +107 -0
- package/i18n/locales/pl.json +107 -0
- package/i18n/locales/tr.json +107 -0
- package/i18n/locales/uk.json +107 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
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
|
+
})
|