@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
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The OUTCOME summary — the non-code answer to "what did this run change, and what backs that
|
|
3
|
+
// up", and the surface "read the result" lands on in basic mode.
|
|
4
|
+
//
|
|
5
|
+
// Reading a finished run meant reading a pull request: a branch, a title, a diff. Everything a
|
|
6
|
+
// person who does not read diffs needs was already captured and each piece sat behind its own
|
|
7
|
+
// step-keyed window, so it was reachable only by someone who had learned the pipeline. This
|
|
8
|
+
// window is the one place they come together, keyed by the RUN: what was asked, which
|
|
9
|
+
// requirements were verified, how it was tested, what it looks like, and which checks ran. The
|
|
10
|
+
// diff is one click from the top of the card rather than the thing you start on.
|
|
11
|
+
//
|
|
12
|
+
// It composes NOTHING itself: `composeRunOutcome` (`~/utils/runOutcome`) is the pure reduction,
|
|
13
|
+
// so the rules that matter (a regression is an `established` requirement observed to fail; an
|
|
14
|
+
// absent producer never renders as a clean result) are unit-tested without mounting this. What
|
|
15
|
+
// lives here is presentation only, plus the ONE fetch the card owns: the enclosing service's
|
|
16
|
+
// spec, which turns the tester's requirement IDS into the requirement TITLES a reader came for.
|
|
17
|
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
18
|
+
import type {
|
|
19
|
+
OutcomeCheckKind,
|
|
20
|
+
OutcomeCheckState,
|
|
21
|
+
OutcomeDisposition,
|
|
22
|
+
OutcomeSpecJoin,
|
|
23
|
+
OutcomeVisual,
|
|
24
|
+
RequirementsGap,
|
|
25
|
+
TestsGap,
|
|
26
|
+
TestsVerdict,
|
|
27
|
+
VisualsGap,
|
|
28
|
+
} from '~/utils/runOutcome'
|
|
29
|
+
import { composeRunOutcome } from '~/utils/runOutcome'
|
|
30
|
+
import { REPRODUCTION_STATUS_KEYS } from '~/utils/reproduction'
|
|
31
|
+
import type { RequirementVerdictStatus, TestConcernSeverity } from '~/types/domain'
|
|
32
|
+
import type { TestEnvironment } from '@cat-factory/contracts'
|
|
33
|
+
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
34
|
+
import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
35
|
+
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
36
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
37
|
+
import EmptyState from '~/components/common/EmptyState.vue'
|
|
38
|
+
|
|
39
|
+
const board = useBoardStore()
|
|
40
|
+
const execution = useExecutionStore()
|
|
41
|
+
const serviceSpec = useServiceSpecStore()
|
|
42
|
+
const ui = useUiStore()
|
|
43
|
+
const { t } = useI18n()
|
|
44
|
+
|
|
45
|
+
// Per-window blob cache for the captured views; revoked on unmount so the (large) image bytes
|
|
46
|
+
// don't outlive the card.
|
|
47
|
+
const blobs = useArtifactBlobs()
|
|
48
|
+
onUnmounted(() => blobs.revokeAll())
|
|
49
|
+
|
|
50
|
+
// The shared seam contract. The `onOpen` loader fetches the ENCLOSING SERVICE's spec: the
|
|
51
|
+
// requirement verdicts are keyed by the spec's own ids, and without it the coverage section can
|
|
52
|
+
// only show ids (which it then says, rather than letting an id read as a title).
|
|
53
|
+
const { open, blockId, instanceId, close } = useResultView('outcome', {
|
|
54
|
+
onOpen: (view) => {
|
|
55
|
+
const block = board.getBlock(view.blockId)
|
|
56
|
+
const service = block ? board.serviceOf(block) : undefined
|
|
57
|
+
if (service) void serviceSpec.load(service.id)
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
62
|
+
const service = computed(() => (block.value ? board.serviceOf(block.value) : undefined))
|
|
63
|
+
const instance = computed(() => {
|
|
64
|
+
// The run carried by the opener, else the block's own live run: a card opened from a
|
|
65
|
+
// notification names the run, one opened from the board does not.
|
|
66
|
+
const id = instanceId.value ?? block.value?.executionId ?? null
|
|
67
|
+
return id ? (execution.getInstance(id) ?? null) : null
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const outcome = computed(() =>
|
|
71
|
+
block.value
|
|
72
|
+
? composeRunOutcome({
|
|
73
|
+
block: block.value,
|
|
74
|
+
instance: instance.value,
|
|
75
|
+
spec: service.value ? serviceSpec.viewFor(service.value.id) : null,
|
|
76
|
+
})
|
|
77
|
+
: null,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// ---- Presentation maps (exhaustive over the closed unions, so a new member fails the
|
|
81
|
+
// typecheck here rather than rendering blank on the one surface whose job is to say what is
|
|
82
|
+
// known and what is not).
|
|
83
|
+
|
|
84
|
+
const DISPOSITION_KEYS: Record<OutcomeDisposition, string> = {
|
|
85
|
+
merged: 'outcome.disposition.merged',
|
|
86
|
+
awaiting_merge: 'outcome.disposition.awaiting_merge',
|
|
87
|
+
in_flight: 'outcome.disposition.in_flight',
|
|
88
|
+
needs_attention: 'outcome.disposition.needs_attention',
|
|
89
|
+
not_run: 'outcome.disposition.not_run',
|
|
90
|
+
unknown: 'outcome.disposition.unknown',
|
|
91
|
+
}
|
|
92
|
+
/** The badge palette, named once so every colour map below is checked against it. */
|
|
93
|
+
type BadgeColor = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
94
|
+
|
|
95
|
+
const DISPOSITION_COLOR: Record<OutcomeDisposition, BadgeColor> = {
|
|
96
|
+
merged: 'success',
|
|
97
|
+
awaiting_merge: 'info',
|
|
98
|
+
in_flight: 'primary',
|
|
99
|
+
needs_attention: 'error',
|
|
100
|
+
not_run: 'neutral',
|
|
101
|
+
unknown: 'neutral',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A run this card could not resolve is the SAME fact in every section, so all three name one
|
|
105
|
+
// key: it is about the read, not about what any particular producer did or did not do.
|
|
106
|
+
const RUN_UNAVAILABLE_KEY = 'outcome.gap.run_unavailable'
|
|
107
|
+
|
|
108
|
+
const REQUIREMENTS_GAP_KEYS: Record<RequirementsGap, string> = {
|
|
109
|
+
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
110
|
+
no_tester_step: 'outcome.requirements.gap.no_tester_step',
|
|
111
|
+
tester_not_reported: 'outcome.requirements.gap.tester_not_reported',
|
|
112
|
+
no_verdicts: 'outcome.requirements.gap.no_verdicts',
|
|
113
|
+
}
|
|
114
|
+
const TESTS_GAP_KEYS: Record<TestsGap, string> = {
|
|
115
|
+
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
116
|
+
no_tester_step: 'outcome.tests.gap.no_tester_step',
|
|
117
|
+
tester_not_reported: 'outcome.tests.gap.tester_not_reported',
|
|
118
|
+
}
|
|
119
|
+
const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
|
|
120
|
+
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
121
|
+
no_visual_step: 'outcome.visuals.gap.no_visual_step',
|
|
122
|
+
none_captured: 'outcome.visuals.gap.none_captured',
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Why the rows carry no spec titles. `joined` is excluded rather than mapped to an empty
|
|
126
|
+
* string, so the note renders only where there is one to make and a new join state cannot ship
|
|
127
|
+
* without copy of its own.
|
|
128
|
+
*/
|
|
129
|
+
const SPEC_JOIN_KEYS: Record<Exclude<OutcomeSpecJoin, 'joined'>, string> = {
|
|
130
|
+
not_read: 'outcome.requirements.spec.not_read',
|
|
131
|
+
unmatched: 'outcome.requirements.spec.unmatched',
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const VERDICT_META: Record<RequirementVerdictStatus, { color: string; key: string }> = {
|
|
135
|
+
met: { color: '#22c55e', key: 'outcome.requirements.verdict.met' },
|
|
136
|
+
not_met: { color: '#ef4444', key: 'outcome.requirements.verdict.not_met' },
|
|
137
|
+
not_covered: { color: '#64748b', key: 'outcome.requirements.verdict.not_covered' },
|
|
138
|
+
}
|
|
139
|
+
const SEVERITY_KEYS: Record<TestConcernSeverity, string> = {
|
|
140
|
+
low: 'outcome.tests.severity.low',
|
|
141
|
+
medium: 'outcome.tests.severity.medium',
|
|
142
|
+
high: 'outcome.tests.severity.high',
|
|
143
|
+
critical: 'outcome.tests.severity.critical',
|
|
144
|
+
}
|
|
145
|
+
const SEVERITY_COLOR: Record<TestConcernSeverity, BadgeColor> = {
|
|
146
|
+
low: 'neutral',
|
|
147
|
+
medium: 'warning',
|
|
148
|
+
high: 'warning',
|
|
149
|
+
critical: 'error',
|
|
150
|
+
}
|
|
151
|
+
const ENVIRONMENT_KEYS: Record<TestEnvironment, string> = {
|
|
152
|
+
local: 'outcome.tests.environment.local',
|
|
153
|
+
ephemeral: 'outcome.tests.environment.ephemeral',
|
|
154
|
+
}
|
|
155
|
+
const CHECK_KEYS: Record<OutcomeCheckKind, string> = {
|
|
156
|
+
ci: 'outcome.checks.kind.ci',
|
|
157
|
+
validation: 'outcome.checks.kind.validation',
|
|
158
|
+
reproduction: 'outcome.checks.kind.reproduction',
|
|
159
|
+
}
|
|
160
|
+
const CHECK_STATE_KEYS: Record<OutcomeCheckState, string> = {
|
|
161
|
+
pass: 'outcome.checks.state.pass',
|
|
162
|
+
fail: 'outcome.checks.state.fail',
|
|
163
|
+
pending: 'outcome.checks.state.pending',
|
|
164
|
+
inconclusive: 'outcome.checks.state.inconclusive',
|
|
165
|
+
}
|
|
166
|
+
const CHECK_STATE_COLOR: Record<OutcomeCheckState, BadgeColor> = {
|
|
167
|
+
pass: 'success',
|
|
168
|
+
fail: 'error',
|
|
169
|
+
pending: 'info',
|
|
170
|
+
inconclusive: 'warning',
|
|
171
|
+
}
|
|
172
|
+
const TESTS_VERDICT_KEYS: Record<TestsVerdict, string> = {
|
|
173
|
+
greenlit: 'outcome.tests.verdict.greenlit',
|
|
174
|
+
concerns: 'outcome.tests.verdict.concerns',
|
|
175
|
+
could_not_run: 'outcome.tests.verdict.could_not_run',
|
|
176
|
+
}
|
|
177
|
+
const TESTS_VERDICT_COLOR: Record<TestsVerdict, BadgeColor> = {
|
|
178
|
+
greenlit: 'success',
|
|
179
|
+
concerns: 'warning',
|
|
180
|
+
could_not_run: 'error',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Derived view state ----------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const headerTitle = computed(() => outcome.value?.title ?? t('outcome.title'))
|
|
186
|
+
const disposition = computed(() => outcome.value?.disposition ?? 'not_run')
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The note under the requirement counts when the rows carry no spec titles, null when they do.
|
|
190
|
+
* Resolved here so the `joined` exclusion is checked by the compiler once, rather than by a
|
|
191
|
+
* template condition that would silently render nothing if the union grew.
|
|
192
|
+
*/
|
|
193
|
+
const specNote = computed(() => {
|
|
194
|
+
const requirements = outcome.value?.requirements
|
|
195
|
+
if (!requirements || requirements.status !== 'reported' || requirements.spec === 'joined') {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
return t(SPEC_JOIN_KEYS[requirements.spec])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The requirement rows, each carrying whether its id is standing in for a title it has no way
|
|
203
|
+
* to show. Marked per row ONLY where the section as a whole joined: an id sitting unmarked
|
|
204
|
+
* between two named requirements reads as a requirement someone named after a slug, while a
|
|
205
|
+
* marker on every row of a section the note above already explains is just noise.
|
|
206
|
+
*/
|
|
207
|
+
const requirementRows = computed(() => {
|
|
208
|
+
const requirements = outcome.value?.requirements
|
|
209
|
+
if (!requirements || requirements.status !== 'reported') return []
|
|
210
|
+
return requirements.entries.map((entry) => ({
|
|
211
|
+
...entry,
|
|
212
|
+
idOnly: requirements.spec === 'joined' && entry.title === null,
|
|
213
|
+
}))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/** The captured views, resolved to blobs as they arrive (the card shows them inline). */
|
|
217
|
+
const views = computed(() =>
|
|
218
|
+
outcome.value?.visuals.status === 'reported' ? outcome.value.visuals.views : [],
|
|
219
|
+
)
|
|
220
|
+
watch(
|
|
221
|
+
views,
|
|
222
|
+
(next) => {
|
|
223
|
+
for (const v of next) if (v.artifactId) void blobs.resolve(v.artifactId)
|
|
224
|
+
},
|
|
225
|
+
{ immediate: true },
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const lightboxItems = computed(() =>
|
|
229
|
+
views.value.flatMap((v) =>
|
|
230
|
+
v.artifactId
|
|
231
|
+
? [
|
|
232
|
+
{
|
|
233
|
+
artifactId: v.artifactId,
|
|
234
|
+
label: v.view,
|
|
235
|
+
alt: t('outcome.visuals.shotAlt', { view: v.view }),
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
: [],
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
const lightboxOpen = ref(false)
|
|
242
|
+
const lightboxIndex = ref(0)
|
|
243
|
+
/**
|
|
244
|
+
* Open the zoom viewer on a captured view. A view whose capture is missing has nothing to open.
|
|
245
|
+
*
|
|
246
|
+
* The viewer's index is counted over the views that HAVE a capture, in order, rather than
|
|
247
|
+
* looked up by artifact id: two views of one artifact (a gate that captured a shared reference,
|
|
248
|
+
* a re-captured view) are distinct rows that would otherwise both open the first of them.
|
|
249
|
+
*/
|
|
250
|
+
function openShot(view: OutcomeVisual, position: number) {
|
|
251
|
+
if (!view.artifactId) return
|
|
252
|
+
lightboxIndex.value = views.value.slice(0, position).filter((v) => v.artifactId).length
|
|
253
|
+
lightboxOpen.value = true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The recorded machine checks as chips. The reproduction row names its OWN verdict through the
|
|
258
|
+
* shared presentation map instead of the generic state word: `inconclusive` and
|
|
259
|
+
* `declared_infeasible` are both "not proof" and call for different reactions, so collapsing
|
|
260
|
+
* them onto one label is the reporting failure this card exists to avoid.
|
|
261
|
+
*/
|
|
262
|
+
const checkRows = computed(() =>
|
|
263
|
+
(outcome.value?.checks ?? []).map((check) => ({
|
|
264
|
+
kind: check.kind,
|
|
265
|
+
color: CHECK_STATE_COLOR[check.state],
|
|
266
|
+
label: t('outcome.checks.row', {
|
|
267
|
+
kind: t(CHECK_KEYS[check.kind]),
|
|
268
|
+
state: check.reproduction
|
|
269
|
+
? t(REPRODUCTION_STATUS_KEYS[check.reproduction].chip)
|
|
270
|
+
: t(CHECK_STATE_KEYS[check.state]),
|
|
271
|
+
}),
|
|
272
|
+
})),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
/** Drill into the full test report (this card is the summary, never a replacement for it). */
|
|
276
|
+
function openTestReport() {
|
|
277
|
+
if (instance.value) ui.openTestEvidence(instance.value.id)
|
|
278
|
+
}
|
|
279
|
+
</script>
|
|
280
|
+
|
|
281
|
+
<template>
|
|
282
|
+
<ResultWindowShell
|
|
283
|
+
:open="open"
|
|
284
|
+
icon="i-lucide-clipboard-check"
|
|
285
|
+
icon-class="bg-sky-500/15 text-sky-300"
|
|
286
|
+
:title="headerTitle"
|
|
287
|
+
:subtitle="t('outcome.subtitle')"
|
|
288
|
+
width="3xl"
|
|
289
|
+
testid="outcome-window"
|
|
290
|
+
@close="close"
|
|
291
|
+
>
|
|
292
|
+
<div v-if="outcome" class="min-h-0 flex-1 overflow-y-auto px-5 py-4" data-testid="outcome-body">
|
|
293
|
+
<!-- Where the work stands, and the way to the diff. The pull requests sit at the TOP so
|
|
294
|
+
the code is exactly one click from the summary rather than the thing you start on. -->
|
|
295
|
+
<div class="mb-4 flex flex-wrap items-center gap-2">
|
|
296
|
+
<UBadge
|
|
297
|
+
:color="DISPOSITION_COLOR[disposition]"
|
|
298
|
+
variant="subtle"
|
|
299
|
+
size="md"
|
|
300
|
+
data-testid="outcome-disposition"
|
|
301
|
+
:data-disposition="disposition"
|
|
302
|
+
>
|
|
303
|
+
{{ t(DISPOSITION_KEYS[disposition]) }}
|
|
304
|
+
</UBadge>
|
|
305
|
+
<UButton
|
|
306
|
+
v-for="pr in outcome.pullRequests"
|
|
307
|
+
:key="pr.url"
|
|
308
|
+
:to="pr.url"
|
|
309
|
+
target="_blank"
|
|
310
|
+
rel="noopener"
|
|
311
|
+
external
|
|
312
|
+
color="neutral"
|
|
313
|
+
variant="soft"
|
|
314
|
+
size="xs"
|
|
315
|
+
icon="i-lucide-git-pull-request"
|
|
316
|
+
trailing-icon="i-lucide-external-link"
|
|
317
|
+
data-testid="outcome-pr-link"
|
|
318
|
+
>
|
|
319
|
+
{{
|
|
320
|
+
pr.repo
|
|
321
|
+
? t('outcome.peerDiff', { repo: pr.repo })
|
|
322
|
+
: pr.number
|
|
323
|
+
? t('outcome.diffNumbered', { number: pr.number })
|
|
324
|
+
: t('outcome.diff')
|
|
325
|
+
}}
|
|
326
|
+
</UButton>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<!-- What was asked, in the requester's own words. -->
|
|
330
|
+
<section class="mb-5">
|
|
331
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
332
|
+
{{ t('outcome.ask.title') }}
|
|
333
|
+
</h3>
|
|
334
|
+
<MarkdownProse
|
|
335
|
+
v-if="outcome.ask"
|
|
336
|
+
:text="outcome.ask"
|
|
337
|
+
class="text-[13px] leading-relaxed text-slate-300"
|
|
338
|
+
data-testid="outcome-ask"
|
|
339
|
+
/>
|
|
340
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
341
|
+
{{ t('outcome.ask.none') }}
|
|
342
|
+
</p>
|
|
343
|
+
</section>
|
|
344
|
+
|
|
345
|
+
<!-- Requirement coverage: which required behaviours were checked, and what was seen. -->
|
|
346
|
+
<section class="mb-5" data-testid="outcome-requirements">
|
|
347
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
348
|
+
{{ t('outcome.requirements.title') }}
|
|
349
|
+
</h3>
|
|
350
|
+
<template v-if="outcome.requirements.status === 'reported'">
|
|
351
|
+
<div class="mb-2 flex flex-wrap items-center gap-1.5">
|
|
352
|
+
<UBadge color="success" variant="subtle" size="sm">
|
|
353
|
+
{{ t('outcome.requirements.met', { count: outcome.requirements.met }) }}
|
|
354
|
+
</UBadge>
|
|
355
|
+
<UBadge color="error" variant="subtle" size="sm">
|
|
356
|
+
{{ t('outcome.requirements.notMet', { count: outcome.requirements.notMet }) }}
|
|
357
|
+
</UBadge>
|
|
358
|
+
<UBadge color="neutral" variant="subtle" size="sm">
|
|
359
|
+
{{ t('outcome.requirements.notCovered', { count: outcome.requirements.notCovered }) }}
|
|
360
|
+
</UBadge>
|
|
361
|
+
<UBadge
|
|
362
|
+
v-if="outcome.requirements.regressions > 0"
|
|
363
|
+
color="error"
|
|
364
|
+
variant="solid"
|
|
365
|
+
size="sm"
|
|
366
|
+
icon="i-lucide-triangle-alert"
|
|
367
|
+
data-testid="outcome-regressions"
|
|
368
|
+
>
|
|
369
|
+
{{
|
|
370
|
+
t('outcome.requirements.regressions', {
|
|
371
|
+
count: outcome.requirements.regressions,
|
|
372
|
+
})
|
|
373
|
+
}}
|
|
374
|
+
</UBadge>
|
|
375
|
+
</div>
|
|
376
|
+
<!-- The ids are all there is: say WHICH reason, rather than letting a slug read as
|
|
377
|
+
the name of a requirement (never read, versus read and naming none of these). -->
|
|
378
|
+
<p
|
|
379
|
+
v-if="specNote"
|
|
380
|
+
class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
|
|
381
|
+
data-testid="outcome-spec-note"
|
|
382
|
+
>
|
|
383
|
+
{{ specNote }}
|
|
384
|
+
</p>
|
|
385
|
+
<ul class="space-y-1.5">
|
|
386
|
+
<li
|
|
387
|
+
v-for="req in requirementRows"
|
|
388
|
+
:key="req.id"
|
|
389
|
+
class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-950/40 p-2"
|
|
390
|
+
data-testid="outcome-requirement"
|
|
391
|
+
>
|
|
392
|
+
<span
|
|
393
|
+
class="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
|
394
|
+
:style="{ backgroundColor: VERDICT_META[req.verdict].color }"
|
|
395
|
+
/>
|
|
396
|
+
<div class="min-w-0">
|
|
397
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
398
|
+
<span class="text-[13px] text-slate-200">{{ req.title ?? req.id }}</span>
|
|
399
|
+
<!-- This row's id is standing in for a title the spec does not have for it,
|
|
400
|
+
beside rows that DO carry one. -->
|
|
401
|
+
<UBadge
|
|
402
|
+
v-if="req.idOnly"
|
|
403
|
+
color="neutral"
|
|
404
|
+
variant="subtle"
|
|
405
|
+
size="sm"
|
|
406
|
+
:title="t('outcome.requirements.idOnlyHint')"
|
|
407
|
+
data-testid="outcome-requirement-id-only"
|
|
408
|
+
>
|
|
409
|
+
{{ t('outcome.requirements.idOnly') }}
|
|
410
|
+
</UBadge>
|
|
411
|
+
<UBadge
|
|
412
|
+
v-if="req.regression"
|
|
413
|
+
color="error"
|
|
414
|
+
variant="subtle"
|
|
415
|
+
size="sm"
|
|
416
|
+
data-testid="outcome-requirement-regression"
|
|
417
|
+
>
|
|
418
|
+
{{ t('outcome.requirements.regressionTag') }}
|
|
419
|
+
</UBadge>
|
|
420
|
+
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
421
|
+
{{ t(VERDICT_META[req.verdict].key) }}
|
|
422
|
+
</span>
|
|
423
|
+
</div>
|
|
424
|
+
<p v-if="req.detail" class="mt-0.5 text-[12px] leading-relaxed text-slate-400">
|
|
425
|
+
{{ req.detail }}
|
|
426
|
+
</p>
|
|
427
|
+
</div>
|
|
428
|
+
</li>
|
|
429
|
+
</ul>
|
|
430
|
+
</template>
|
|
431
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
432
|
+
{{ t(REQUIREMENTS_GAP_KEYS[outcome.requirements.gap]) }}
|
|
433
|
+
</p>
|
|
434
|
+
</section>
|
|
435
|
+
|
|
436
|
+
<!-- How it was tested: the tester's own verdict and prose, attributed as its account. -->
|
|
437
|
+
<section class="mb-5" data-testid="outcome-tests">
|
|
438
|
+
<div class="mb-1.5 flex flex-wrap items-center gap-2">
|
|
439
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
440
|
+
{{ t('outcome.tests.title') }}
|
|
441
|
+
</h3>
|
|
442
|
+
<UBadge
|
|
443
|
+
v-if="outcome.tests.status === 'reported'"
|
|
444
|
+
:color="TESTS_VERDICT_COLOR[outcome.tests.verdict]"
|
|
445
|
+
variant="subtle"
|
|
446
|
+
size="sm"
|
|
447
|
+
data-testid="outcome-tests-verdict"
|
|
448
|
+
>
|
|
449
|
+
{{ t(TESTS_VERDICT_KEYS[outcome.tests.verdict]) }}
|
|
450
|
+
</UBadge>
|
|
451
|
+
<UBadge
|
|
452
|
+
v-if="outcome.tests.status === 'reported' && outcome.tests.environment"
|
|
453
|
+
color="neutral"
|
|
454
|
+
variant="subtle"
|
|
455
|
+
size="sm"
|
|
456
|
+
>
|
|
457
|
+
{{ t(ENVIRONMENT_KEYS[outcome.tests.environment]) }}
|
|
458
|
+
</UBadge>
|
|
459
|
+
<UButton
|
|
460
|
+
v-if="outcome.tests.status === 'reported' && instance"
|
|
461
|
+
color="neutral"
|
|
462
|
+
variant="ghost"
|
|
463
|
+
size="xs"
|
|
464
|
+
icon="i-lucide-flask-conical"
|
|
465
|
+
data-testid="outcome-open-test-report"
|
|
466
|
+
@click="openTestReport"
|
|
467
|
+
>
|
|
468
|
+
{{ t('outcome.tests.openReport') }}
|
|
469
|
+
</UButton>
|
|
470
|
+
</div>
|
|
471
|
+
<template v-if="outcome.tests.status === 'reported'">
|
|
472
|
+
<p
|
|
473
|
+
v-if="outcome.tests.abortReason"
|
|
474
|
+
class="mb-2 rounded-md border border-rose-900/70 bg-rose-500/10 p-2 text-[13px] leading-relaxed text-rose-200"
|
|
475
|
+
data-testid="outcome-tests-abort"
|
|
476
|
+
>
|
|
477
|
+
{{ t('outcome.tests.abort', { reason: outcome.tests.abortReason }) }}
|
|
478
|
+
</p>
|
|
479
|
+
<p v-if="outcome.tests.summary" class="text-[13px] leading-relaxed text-slate-300">
|
|
480
|
+
{{ t('outcome.tests.summary', { summary: outcome.tests.summary }) }}
|
|
481
|
+
</p>
|
|
482
|
+
<p class="mt-1.5 text-[12px] text-slate-400">
|
|
483
|
+
{{
|
|
484
|
+
t('outcome.tests.counts', {
|
|
485
|
+
passed: outcome.tests.passed,
|
|
486
|
+
failed: outcome.tests.failed,
|
|
487
|
+
skipped: outcome.tests.skipped,
|
|
488
|
+
})
|
|
489
|
+
}}
|
|
490
|
+
</p>
|
|
491
|
+
<ul v-if="outcome.tests.concerns.length" class="mt-2 space-y-1">
|
|
492
|
+
<li
|
|
493
|
+
v-for="(concern, i) in outcome.tests.concerns"
|
|
494
|
+
:key="i"
|
|
495
|
+
class="flex items-start gap-2 text-[12px] text-slate-300"
|
|
496
|
+
data-testid="outcome-concern"
|
|
497
|
+
>
|
|
498
|
+
<UBadge :color="SEVERITY_COLOR[concern.severity]" variant="subtle" size="sm">
|
|
499
|
+
{{ t(SEVERITY_KEYS[concern.severity]) }}
|
|
500
|
+
</UBadge>
|
|
501
|
+
<span class="min-w-0">{{ concern.title }}</span>
|
|
502
|
+
</li>
|
|
503
|
+
</ul>
|
|
504
|
+
</template>
|
|
505
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
506
|
+
{{ t(TESTS_GAP_KEYS[outcome.tests.gap]) }}
|
|
507
|
+
</p>
|
|
508
|
+
</section>
|
|
509
|
+
|
|
510
|
+
<!-- What it looks like: the captured views, and whether a human was asked about them. -->
|
|
511
|
+
<section class="mb-5" data-testid="outcome-visuals">
|
|
512
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
513
|
+
{{ t('outcome.visuals.title') }}
|
|
514
|
+
</h3>
|
|
515
|
+
<template v-if="outcome.visuals.status === 'reported'">
|
|
516
|
+
<p class="mb-2 text-[12px] leading-relaxed text-slate-400">
|
|
517
|
+
{{
|
|
518
|
+
outcome.visuals.source === 'visual_confirm'
|
|
519
|
+
? t('outcome.visuals.source.visual_confirm')
|
|
520
|
+
: t('outcome.visuals.source.tester')
|
|
521
|
+
}}
|
|
522
|
+
</p>
|
|
523
|
+
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
524
|
+
<button
|
|
525
|
+
v-for="(view, position) in outcome.visuals.views"
|
|
526
|
+
:key="`${position}:${view.view}`"
|
|
527
|
+
type="button"
|
|
528
|
+
class="group overflow-hidden rounded-md border border-slate-800 bg-slate-950/60 text-start transition hover:border-slate-600"
|
|
529
|
+
:disabled="!view.artifactId"
|
|
530
|
+
data-testid="outcome-shot"
|
|
531
|
+
@click="openShot(view, position)"
|
|
532
|
+
>
|
|
533
|
+
<img
|
|
534
|
+
v-if="view.artifactId && blobs.urlFor(view.artifactId)"
|
|
535
|
+
:src="blobs.urlFor(view.artifactId)"
|
|
536
|
+
:alt="t('outcome.visuals.shotAlt', { view: view.view })"
|
|
537
|
+
class="h-24 w-full object-cover object-top"
|
|
538
|
+
/>
|
|
539
|
+
<div v-else class="flex h-24 w-full items-center justify-center text-slate-600">
|
|
540
|
+
<UIcon
|
|
541
|
+
:name="
|
|
542
|
+
view.artifactId && blobs.statusFor(view.artifactId) === 'error'
|
|
543
|
+
? 'i-lucide-image-off'
|
|
544
|
+
: 'i-lucide-image'
|
|
545
|
+
"
|
|
546
|
+
class="h-5 w-5"
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
<span
|
|
550
|
+
class="flex items-center gap-1 truncate px-1.5 py-1 text-[11px] text-slate-300"
|
|
551
|
+
:title="view.view"
|
|
552
|
+
>
|
|
553
|
+
<UIcon
|
|
554
|
+
v-if="view.referenceArtifactId"
|
|
555
|
+
name="i-lucide-images"
|
|
556
|
+
class="h-3 w-3 shrink-0 text-sky-300"
|
|
557
|
+
:title="t('outcome.visuals.hasReference')"
|
|
558
|
+
/>
|
|
559
|
+
{{ view.view }}
|
|
560
|
+
</span>
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</template>
|
|
564
|
+
<template v-else>
|
|
565
|
+
<p class="text-[13px] italic leading-relaxed text-slate-500">
|
|
566
|
+
{{ t(VISUALS_GAP_KEYS[outcome.visuals.gap]) }}
|
|
567
|
+
</p>
|
|
568
|
+
<p
|
|
569
|
+
v-if="outcome.visuals.detail"
|
|
570
|
+
class="mt-1 text-[12px] leading-relaxed text-slate-500"
|
|
571
|
+
data-testid="outcome-visuals-detail"
|
|
572
|
+
>
|
|
573
|
+
{{ outcome.visuals.detail }}
|
|
574
|
+
</p>
|
|
575
|
+
</template>
|
|
576
|
+
</section>
|
|
577
|
+
|
|
578
|
+
<!-- The machine checks, listed only where one actually recorded a verdict. -->
|
|
579
|
+
<section v-if="checkRows.length" data-testid="outcome-checks">
|
|
580
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
581
|
+
{{ t('outcome.checks.title') }}
|
|
582
|
+
</h3>
|
|
583
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
584
|
+
<UBadge
|
|
585
|
+
v-for="check in checkRows"
|
|
586
|
+
:key="check.kind"
|
|
587
|
+
:color="check.color"
|
|
588
|
+
variant="subtle"
|
|
589
|
+
size="sm"
|
|
590
|
+
data-testid="outcome-check"
|
|
591
|
+
:data-check="check.kind"
|
|
592
|
+
>
|
|
593
|
+
{{ check.label }}
|
|
594
|
+
</UBadge>
|
|
595
|
+
</div>
|
|
596
|
+
</section>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<EmptyState
|
|
600
|
+
v-else
|
|
601
|
+
icon="i-lucide-clipboard-check"
|
|
602
|
+
:title="t('outcome.empty.title')"
|
|
603
|
+
:description="t('outcome.empty.body')"
|
|
604
|
+
/>
|
|
605
|
+
</ResultWindowShell>
|
|
606
|
+
|
|
607
|
+
<!-- Shared zoom/pan viewer, layered above this window on the shared modal stack. -->
|
|
608
|
+
<ArtifactLightbox
|
|
609
|
+
v-model:open="lightboxOpen"
|
|
610
|
+
v-model:index="lightboxIndex"
|
|
611
|
+
:items="lightboxItems"
|
|
612
|
+
:blobs="blobs"
|
|
613
|
+
/>
|
|
614
|
+
</template>
|
|
@@ -59,6 +59,10 @@ const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
|
|
|
59
59
|
why: 'tracker column + run-metadata rail, and it hands its whole body to the three-column plan review while a plan is parked',
|
|
60
60
|
},
|
|
61
61
|
'judge/JudgeResultView.vue': { width: '3xl', why: 'a rubric verdict — one column, short' },
|
|
62
|
+
'outcome/OutcomeSummaryWindow.vue': {
|
|
63
|
+
width: '3xl',
|
|
64
|
+
why: 'the run outcome summary: one column of short evidence sections, no rail to lay out beside it',
|
|
65
|
+
},
|
|
62
66
|
'panels/GenericStructuredResultView.vue': {
|
|
63
67
|
width: '4xl',
|
|
64
68
|
why: 'the fallback structured-result reader, one column of sections',
|
|
@@ -19,6 +19,7 @@ import type { ChangeClass, ReviewEffort } from '~/types/merge'
|
|
|
19
19
|
import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
|
|
20
20
|
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
21
21
|
import { inputGateNoticeFor } from '~/utils/inputGate'
|
|
22
|
+
import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
|
|
22
23
|
|
|
23
24
|
const props = defineProps<{ block: Block }>()
|
|
24
25
|
|
|
@@ -87,6 +88,22 @@ const failedRun = computed(() => {
|
|
|
87
88
|
// CURRENT status, so the error trail stays viewable after a restart clears the top banner.
|
|
88
89
|
const failureHistory = computed(() => agentRuns.byBlock[props.block.id]?.failureHistory ?? [])
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether this task has a result worth reading in product terms: a pull request, or a step that
|
|
93
|
+
* recorded evidence. Asked of the shared reduction rather than re-derived here, so the panel and
|
|
94
|
+
* the board card can never disagree about which runs have an outcome to open.
|
|
95
|
+
*
|
|
96
|
+
* The spec is deliberately not loaded for this check: it only ever adds TITLES to requirement
|
|
97
|
+
* rows, never sections, so nothing about whether there is something to show depends on it. The
|
|
98
|
+
* window loads it on open.
|
|
99
|
+
*/
|
|
100
|
+
const outcomeReadable = computed(() =>
|
|
101
|
+
hasOutcomeToShow(composeRunOutcome({ block: props.block, instance: instance.value ?? null })),
|
|
102
|
+
)
|
|
103
|
+
function openOutcome() {
|
|
104
|
+
ui.openOutcome(props.block.id, instance.value?.id ?? null)
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
const pr = computed(() => props.block.pullRequest)
|
|
91
108
|
/** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
|
|
92
109
|
const prMerged = computed(() => props.block.status === 'done')
|
|
@@ -605,6 +622,22 @@ async function mergePr() {
|
|
|
605
622
|
<!-- error trail of prior attempts (survives a retry/restart that cleared the banner) -->
|
|
606
623
|
<AgentFailureHistory :failures="failureHistory" />
|
|
607
624
|
|
|
625
|
+
<!-- Read the result: the outcome summary is the way in, and the pull request below is the
|
|
626
|
+
way to the diff. Offered whenever there is evidence or a PR to read, which includes a
|
|
627
|
+
merged task whose run instance is long gone. -->
|
|
628
|
+
<UButton
|
|
629
|
+
v-if="outcomeReadable"
|
|
630
|
+
color="primary"
|
|
631
|
+
variant="soft"
|
|
632
|
+
size="sm"
|
|
633
|
+
icon="i-lucide-clipboard-check"
|
|
634
|
+
block
|
|
635
|
+
data-testid="inspector-open-outcome"
|
|
636
|
+
@click="openOutcome"
|
|
637
|
+
>
|
|
638
|
+
{{ t('inspector.execution.readOutcome') }}
|
|
639
|
+
</UButton>
|
|
640
|
+
|
|
608
641
|
<!-- Open PR: link straight to it on GitHub -->
|
|
609
642
|
<div v-if="pr" class="space-y-2">
|
|
610
643
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|