@cat-factory/app 0.226.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/MergerResultView.vue +7 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +33 -0
- package/app/components/riskPolicy/RiskPolicyPreview.vue +6 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +65 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +72 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +133 -39
- package/app/components/settings/RiskPolicyPanel.vue +8 -0
- package/app/composables/usePipelineErrorToast.ts +4 -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/types/merge.ts +2 -0
- package/app/utils/riskPolicy.spec.ts +21 -2
- package/app/utils/riskPolicy.ts +12 -0
- package/app/utils/runOutcome.spec.ts +492 -0
- package/app/utils/runOutcome.ts +509 -0
- package/i18n/locales/de.json +124 -6
- package/i18n/locales/en.json +127 -6
- package/i18n/locales/es.json +124 -6
- package/i18n/locales/fr.json +124 -6
- package/i18n/locales/he.json +124 -6
- package/i18n/locales/it.json +124 -6
- package/i18n/locales/ja.json +124 -6
- package/i18n/locales/pl.json +124 -6
- package/i18n/locales/tr.json +124 -6
- package/i18n/locales/uk.json +124 -6
- package/package.json +2 -2
|
@@ -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
|
})
|
package/app/types/merge.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type {
|
|
|
16
16
|
// runs are sandboxed (they open a pull request and merge nothing).
|
|
17
17
|
ClassRulesByRole,
|
|
18
18
|
DryRunRoles,
|
|
19
|
+
// Which change classes a role may LAND at all (absent ⇒ unrestricted, empty ⇒ nothing).
|
|
20
|
+
SubmissionClassesByRole,
|
|
19
21
|
WorkspaceRole,
|
|
20
22
|
MergeTrackRecord,
|
|
21
23
|
ReviewEffort,
|
|
@@ -27,6 +27,7 @@ const policy = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
27
27
|
classRules: {},
|
|
28
28
|
classRulesByRole: {},
|
|
29
29
|
dryRunRoles: [],
|
|
30
|
+
submissionClassesByRole: {},
|
|
30
31
|
isDefault: true,
|
|
31
32
|
version: 1,
|
|
32
33
|
createdAt: 0,
|
|
@@ -61,7 +62,7 @@ describe('riskPolicyCeilings', () => {
|
|
|
61
62
|
|
|
62
63
|
describe('rolePolicySummary', () => {
|
|
63
64
|
it('is empty on a policy that treats every initiator alike', () => {
|
|
64
|
-
expect(rolePolicySummary(policy())).toEqual({ sandboxed: [], narrowed: [] })
|
|
65
|
+
expect(rolePolicySummary(policy())).toEqual({ sandboxed: [], narrowed: [], scoped: [] })
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
it('lists both layers in the shared role order', () => {
|
|
@@ -72,6 +73,7 @@ describe('rolePolicySummary', () => {
|
|
|
72
73
|
expect(rolePolicySummary(p)).toEqual({
|
|
73
74
|
sandboxed: ['member', 'viewer'],
|
|
74
75
|
narrowed: ['admin'],
|
|
76
|
+
scoped: [],
|
|
75
77
|
})
|
|
76
78
|
})
|
|
77
79
|
|
|
@@ -79,6 +81,23 @@ describe('rolePolicySummary', () => {
|
|
|
79
81
|
// limit that changes nothing about what that role's runs can do.
|
|
80
82
|
it('does not also report class rules for a role the policy sandboxes', () => {
|
|
81
83
|
const p = policy({ dryRunRoles: ['member'], classRulesByRole: { member: { docs: 'never' } } })
|
|
82
|
-
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [] })
|
|
84
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('lists a role allowlisted to a subset of change classes', () => {
|
|
88
|
+
const p = policy({ submissionClassesByRole: { member: ['docs'] } })
|
|
89
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: [], narrowed: [], scoped: ['member'] })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// The most restrictive policy this setting can express, and the one a summary must not drop:
|
|
93
|
+
// an EMPTY allowlist is scoped, where an absent entry is unrestricted.
|
|
94
|
+
it('lists a role allowlisted to NOTHING', () => {
|
|
95
|
+
const p = policy({ submissionClassesByRole: { viewer: [] } })
|
|
96
|
+
expect(rolePolicySummary(p).scoped).toEqual(['viewer'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not also report an allowlist for a role the policy sandboxes', () => {
|
|
100
|
+
const p = policy({ dryRunRoles: ['member'], submissionClassesByRole: { member: ['docs'] } })
|
|
101
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
83
102
|
})
|
|
84
103
|
})
|
package/app/utils/riskPolicy.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface RolePolicySummary {
|
|
|
52
52
|
* both would read as two limits where the second changes nothing.
|
|
53
53
|
*/
|
|
54
54
|
narrowed: WorkspaceRole[]
|
|
55
|
+
/**
|
|
56
|
+
* Roles allowlisted to a subset of change classes they may LAND. Same suppression rule as
|
|
57
|
+
* `narrowed` and for the same reason, and listed SEPARATELY from it because the two are not
|
|
58
|
+
* degrees of one setting: this one bars landing outright, including through the manual merge.
|
|
59
|
+
*/
|
|
60
|
+
scoped: WorkspaceRole[]
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -66,5 +72,11 @@ export function rolePolicySummary(p: RiskPolicy): RolePolicySummary {
|
|
|
66
72
|
narrowed: WORKSPACE_ROLES.filter(
|
|
67
73
|
(role) => !sandboxed.includes(role) && Object.keys(p.classRulesByRole[role] ?? {}).length > 0,
|
|
68
74
|
),
|
|
75
|
+
// Scoped-ness is the PRESENCE of an entry, never its length: a role allowlisted to nothing
|
|
76
|
+
// is the most restrictive policy this setting can express, and reading it as "no allowlist"
|
|
77
|
+
// would drop the one summary line a reader most needs to see.
|
|
78
|
+
scoped: WORKSPACE_ROLES.filter(
|
|
79
|
+
(role) => !sandboxed.includes(role) && p.submissionClassesByRole[role] !== undefined,
|
|
80
|
+
),
|
|
69
81
|
}
|
|
70
82
|
}
|
|
@@ -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
|
+
})
|