@cat-factory/app 0.201.1 → 0.204.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 +130 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +296 -40
- package/app/components/tutorial/TutorialPrompt.vue +41 -22
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +189 -53
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +38 -2
- package/app/stores/tutorial.spec.ts +167 -0
- package/app/stores/tutorial.ts +140 -4
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +307 -0
- package/app/utils/binaryOutput.ts +343 -0
- package/app/utils/tutorial.spec.ts +179 -9
- package/app/utils/tutorial.ts +233 -22
- package/i18n/locales/de.json +89 -7
- package/i18n/locales/en.json +101 -7
- package/i18n/locales/es.json +89 -7
- package/i18n/locales/fr.json +89 -7
- package/i18n/locales/he.json +89 -7
- package/i18n/locales/it.json +89 -7
- package/i18n/locales/ja.json +89 -7
- package/i18n/locales/pl.json +89 -7
- package/i18n/locales/tr.json +89 -7
- package/i18n/locales/uk.json +89 -7
- package/package.json +2 -2
package/app/stores/tutorial.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type TutorialDecision = 'accepted' | 'declined'
|
|
|
16
16
|
* is a real state distinct from `declined` — only an explicit answer stops the launch
|
|
17
17
|
* prompt from returning, while closing it without answering merely defers it to the
|
|
18
18
|
* next launch.
|
|
19
|
-
* - SESSION-ONLY (`promptOpen`, `activeTourId`, `stepIndex`) — a tour is anchored to live
|
|
19
|
+
* - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`) — a tour is anchored to live
|
|
20
20
|
* DOM, so replaying progress across a reload would point step N at a board that hasn't
|
|
21
21
|
* reached that state; a reloaded tour restarts from its beginning instead.
|
|
22
22
|
*
|
|
@@ -36,12 +36,47 @@ export const useTutorialStore = defineStore(
|
|
|
36
36
|
const promptOpen = ref(false)
|
|
37
37
|
/** Once-per-session guard for the launch auto-open; later opens are user-driven. */
|
|
38
38
|
const promptAutoOpened = ref(false)
|
|
39
|
+
/**
|
|
40
|
+
* The tutorial catalogue (every tour this deployment ships, startable at any time) is
|
|
41
|
+
* open. Always user-driven — nothing auto-opens it — which is why it carries none of the
|
|
42
|
+
* prompt's decision machinery: browsing the catalogue answers no question, so it neither
|
|
43
|
+
* writes a decision nor consumes the launch offer.
|
|
44
|
+
*/
|
|
45
|
+
const catalogueOpen = ref(false)
|
|
39
46
|
const activeTourId = ref<string | null>(null)
|
|
40
47
|
const stepIndex = ref(0)
|
|
48
|
+
/**
|
|
49
|
+
* Where a tour the user broke off was left, so the prompt can offer to RESUME it rather
|
|
50
|
+
* than only to start it again from step one.
|
|
51
|
+
*
|
|
52
|
+
* Session-only for the same reason the cursor is: a tour is anchored to live DOM, and a
|
|
53
|
+
* position replayed across a reload would point step N at a board that has not reached
|
|
54
|
+
* that state. Within one session the board is still exactly where the tour left it, so the
|
|
55
|
+
* position is good — which matters because breaking off is easy and cheap (Esc, or Skip to
|
|
56
|
+
* get the overlay out of the way for a moment) while the cost of it was the whole
|
|
57
|
+
* walkthrough.
|
|
58
|
+
*/
|
|
59
|
+
const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
|
|
41
60
|
|
|
42
61
|
/** A tour is currently running (the overlay mounts off this). */
|
|
43
62
|
const touring = computed(() => activeTourId.value !== null)
|
|
44
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A window this feature owns — the launch prompt or the catalogue — is on screen.
|
|
66
|
+
*
|
|
67
|
+
* The coach-mark overlay STANDS DOWN while it is (see `TutorialOverlay.vue`). The marks
|
|
68
|
+
* render at `z-[70]`, deliberately above the app's own modals, because a tour step
|
|
69
|
+
* legitimately points INTO one; no step points into the tutorial's own windows, so there
|
|
70
|
+
* the same rule floats a highlight ring and a tooltip over the modal the user just opened.
|
|
71
|
+
* The catalogue reaches this state by design — it is openable mid-tour, which is what the
|
|
72
|
+
* `continue` launch action exists for.
|
|
73
|
+
*
|
|
74
|
+
* A derived FACT rather than a `coachMarksHidden` flag, because the reason is the window,
|
|
75
|
+
* not the overlay: a third tutorial-owned window inherits the behaviour by being named
|
|
76
|
+
* here, and nothing has to remember to set a flag.
|
|
77
|
+
*/
|
|
78
|
+
const ownWindowOpen = computed(() => promptOpen.value || catalogueOpen.value)
|
|
79
|
+
|
|
45
80
|
/**
|
|
46
81
|
* Auto-open the launch prompt, at most once per session and only while the user has
|
|
47
82
|
* never answered it. Callers gate on the rest of the launch context (board ready, no
|
|
@@ -79,6 +114,21 @@ export const useTutorialStore = defineStore(
|
|
|
79
114
|
promptOpen.value = false
|
|
80
115
|
}
|
|
81
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Open the catalogue. Closes the launch prompt WITHOUT answering it: browsing the full
|
|
119
|
+
* list is not "no thanks" (it is the opposite), and the two are modals that would
|
|
120
|
+
* otherwise stack — so the offer returns next launch if the user browses and starts
|
|
121
|
+
* nothing.
|
|
122
|
+
*/
|
|
123
|
+
function openCatalogue() {
|
|
124
|
+
promptOpen.value = false
|
|
125
|
+
catalogueOpen.value = true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function closeCatalogue() {
|
|
129
|
+
catalogueOpen.value = false
|
|
130
|
+
}
|
|
131
|
+
|
|
82
132
|
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
83
133
|
function decline() {
|
|
84
134
|
decision.value = 'declined'
|
|
@@ -93,8 +143,40 @@ export const useTutorialStore = defineStore(
|
|
|
93
143
|
function startTour(tourId: string) {
|
|
94
144
|
decision.value = 'accepted'
|
|
95
145
|
promptOpen.value = false
|
|
146
|
+
catalogueOpen.value = false
|
|
96
147
|
activeTourId.value = tourId
|
|
97
148
|
stepIndex.value = 0
|
|
149
|
+
// Starting from the top is an explicit choice to discard THIS tour's old position;
|
|
150
|
+
// leaving the record in place would offer Resume again the moment this attempt is
|
|
151
|
+
// broken off at step 0, pointing at a position the user already walked away from.
|
|
152
|
+
//
|
|
153
|
+
// A DIFFERENT tour's position is not this action's to discard. It is still exactly what
|
|
154
|
+
// its own Resume offer needs, and it will lose the single slot soon enough — the moment
|
|
155
|
+
// this tour is broken off past step 0. Clearing it here instead means glancing at
|
|
156
|
+
// another tour and pressing Esc silently costs the position you were coming back to.
|
|
157
|
+
if (interrupted.value?.tourId === tourId) interrupted.value = null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pick a broken-off tour back up where it stopped. Falls back to a plain start when the
|
|
162
|
+
* saved position is for a DIFFERENT tour (or gone), so a caller never has to check first
|
|
163
|
+
* and a stale offer degrades to the ordinary behaviour instead of resuming the wrong tour.
|
|
164
|
+
*
|
|
165
|
+
* The index is not validated here: the store deliberately knows nothing about which tours
|
|
166
|
+
* exist or how many steps they have, so the overlay clamps it against the script it holds.
|
|
167
|
+
*/
|
|
168
|
+
function resumeTour(tourId: string) {
|
|
169
|
+
const at = interrupted.value
|
|
170
|
+
if (!at || at.tourId !== tourId) {
|
|
171
|
+
startTour(tourId)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
decision.value = 'accepted'
|
|
175
|
+
promptOpen.value = false
|
|
176
|
+
catalogueOpen.value = false
|
|
177
|
+
activeTourId.value = tourId
|
|
178
|
+
stepIndex.value = at.stepIndex
|
|
179
|
+
interrupted.value = null
|
|
98
180
|
}
|
|
99
181
|
|
|
100
182
|
/** Move the step cursor; the overlay owns bounds/skip logic and never goes below 0. */
|
|
@@ -102,43 +184,97 @@ export const useTutorialStore = defineStore(
|
|
|
102
184
|
stepIndex.value = Math.max(0, index)
|
|
103
185
|
}
|
|
104
186
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
187
|
+
/** Clear the live cursor. Shared by the two ways a tour ends, which differ only in what
|
|
188
|
+
* they leave behind (a resume point vs. a completion). */
|
|
189
|
+
function clearCursor() {
|
|
107
190
|
activeTourId.value = null
|
|
108
191
|
stepIndex.value = 0
|
|
109
192
|
}
|
|
110
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Abandon the running tour without marking it complete: Skip, Esc, or a runtime that
|
|
196
|
+
* could not resolve the tour at all.
|
|
197
|
+
*
|
|
198
|
+
* Records where it stopped so the prompt can offer to resume — except from the very first
|
|
199
|
+
* step, where resuming and starting are the same thing and an offer to "resume" would be
|
|
200
|
+
* noise. `resumable: false` is for the runtime's own bail-outs, which stop BECAUSE the
|
|
201
|
+
* position is unusable and must not hand it back.
|
|
202
|
+
*/
|
|
203
|
+
function stopTour(options?: { resumable?: boolean }) {
|
|
204
|
+
const id = activeTourId.value
|
|
205
|
+
if (id !== null && stepIndex.value > 0 && options?.resumable !== false) {
|
|
206
|
+
interrupted.value = { tourId: id, stepIndex: stepIndex.value }
|
|
207
|
+
}
|
|
208
|
+
clearCursor()
|
|
209
|
+
}
|
|
210
|
+
|
|
111
211
|
/** Finish the running tour: record completion (idempotent) and clear the cursor. */
|
|
112
212
|
function completeTour() {
|
|
113
213
|
const id = activeTourId.value
|
|
114
214
|
if (id && !completedTourIds.value.includes(id)) {
|
|
115
215
|
completedTourIds.value = [...completedTourIds.value, id]
|
|
116
216
|
}
|
|
117
|
-
|
|
217
|
+
// A finished tour has no position left to resume, and an offer to resume the walkthrough
|
|
218
|
+
// the user just completed would sit beside its own Completed badge.
|
|
219
|
+
if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
|
|
220
|
+
clearCursor()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Where this tour was broken off, if it was; null otherwise (the Resume affordance). */
|
|
224
|
+
function interruptedAt(tourId: string): number | null {
|
|
225
|
+
return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
|
|
118
226
|
}
|
|
119
227
|
|
|
120
228
|
function isCompleted(tourId: string): boolean {
|
|
121
229
|
return completedTourIds.value.includes(tourId)
|
|
122
230
|
}
|
|
123
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Forget everything this browser remembers about the tutorial: which tours were finished,
|
|
234
|
+
* where one was broken off, and the answer to the launch offer.
|
|
235
|
+
*
|
|
236
|
+
* The decision goes with it deliberately. "Reset" is asked for by someone handing the app
|
|
237
|
+
* to a colleague, demoing it, or re-walking the product after it changed — and every one
|
|
238
|
+
* of those wants the first-launch experience back, which a cleared completion list alone
|
|
239
|
+
* does not restore. It does NOT re-open the prompt in this session: `promptAutoOpened` is
|
|
240
|
+
* session state and stays spent, so the offer returns at the next launch rather than
|
|
241
|
+
* appearing on top of the catalogue the user is still reading.
|
|
242
|
+
*
|
|
243
|
+
* A running tour is left alone: this clears a record, it does not interrupt a walkthrough
|
|
244
|
+
* the user is in the middle of (which would end it, unrecorded, on a click about history).
|
|
245
|
+
*/
|
|
246
|
+
function resetProgress() {
|
|
247
|
+
completedTourIds.value = []
|
|
248
|
+
interrupted.value = null
|
|
249
|
+
decision.value = null
|
|
250
|
+
}
|
|
251
|
+
|
|
124
252
|
return {
|
|
125
253
|
decision,
|
|
126
254
|
completedTourIds,
|
|
127
255
|
promptOpen,
|
|
128
256
|
promptAutoOpened,
|
|
257
|
+
catalogueOpen,
|
|
129
258
|
activeTourId,
|
|
130
259
|
stepIndex,
|
|
260
|
+
interrupted,
|
|
131
261
|
touring,
|
|
262
|
+
ownWindowOpen,
|
|
132
263
|
maybeOfferOnLaunch,
|
|
133
264
|
openPrompt,
|
|
134
265
|
closePrompt,
|
|
135
266
|
deferPrompt,
|
|
267
|
+
openCatalogue,
|
|
268
|
+
closeCatalogue,
|
|
269
|
+
resetProgress,
|
|
136
270
|
decline,
|
|
137
271
|
startTour,
|
|
272
|
+
resumeTour,
|
|
138
273
|
setStepIndex,
|
|
139
274
|
stopTour,
|
|
140
275
|
completeTour,
|
|
141
276
|
isCompleted,
|
|
277
|
+
interruptedAt,
|
|
142
278
|
}
|
|
143
279
|
},
|
|
144
280
|
{ persist: { pick: ['decision', 'completedTourIds'] } },
|
package/app/types/domain.ts
CHANGED
|
@@ -131,6 +131,15 @@ export interface AgentArchetype {
|
|
|
131
131
|
* hardcoding a kind. Absent → the generic `AgentStepDetail` panel.
|
|
132
132
|
*/
|
|
133
133
|
resultView?: string
|
|
134
|
+
/**
|
|
135
|
+
* The kind carries the `binary-output` trait: its deliverable is binary artifacts stored
|
|
136
|
+
* through a foundational service, so a step of this kind REQUIRES a `stepOptions.binaryOutput`
|
|
137
|
+
* selection and is refused at pipeline save and at run start without one. Projected onto the
|
|
138
|
+
* snapshot as `CustomAgentKind.binaryOutput` — the only trait with a UI consequence, and the
|
|
139
|
+
* only way the builder can know which steps must offer the storage picker. Absent ⇒ false,
|
|
140
|
+
* which is every built-in kind.
|
|
141
|
+
*/
|
|
142
|
+
binaryOutput?: boolean
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/**
|
package/app/types/execution.ts
CHANGED
|
@@ -77,6 +77,11 @@ export type {
|
|
|
77
77
|
RalphStepState,
|
|
78
78
|
RalphAttempt,
|
|
79
79
|
RalphVerdict,
|
|
80
|
+
// The binary-output trio: the step's SELECTION (`stepOptions.binaryOutput`), the report the
|
|
81
|
+
// engine parsed off the agent's declaration (`step.binaryOutputs`), and one declared artifact.
|
|
82
|
+
BinaryOutputArtifact,
|
|
83
|
+
BinaryOutputConfig,
|
|
84
|
+
BinaryOutputReport,
|
|
80
85
|
TesterStepState,
|
|
81
86
|
HumanTestEnvironment,
|
|
82
87
|
RunEnvironment,
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { BinaryOutputReport, PipelineStep } from '~/types/execution'
|
|
3
|
+
import {
|
|
4
|
+
BINARY_OUTPUT_STATE_KEYS,
|
|
5
|
+
binaryOutputHasWarnings,
|
|
6
|
+
binaryOutputPickIssues,
|
|
7
|
+
binaryOutputView,
|
|
8
|
+
} from './binaryOutput'
|
|
9
|
+
|
|
10
|
+
function step(patch: Partial<PipelineStep>): PipelineStep {
|
|
11
|
+
return { agentKind: 'image-maker', state: 'done', ...patch } as PipelineStep
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function report(patch: Partial<BinaryOutputReport> = {}): BinaryOutputReport {
|
|
15
|
+
return { stored: [], unknownServices: [], invalidEntries: 0, omitted: 0, ...patch }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const artifact = (service: string, location: string) => ({ service, location })
|
|
19
|
+
|
|
20
|
+
describe('binaryOutputView', () => {
|
|
21
|
+
// The regression this whole surface exists to prevent: five of the six outcomes are NOT
|
|
22
|
+
// "an empty list", so each must resolve to its own state (and, through the shared key map,
|
|
23
|
+
// its own copy). Collapsing any pair reports a run that stored nothing and a run whose
|
|
24
|
+
// declaration was unreadable as the same thing.
|
|
25
|
+
it('keeps the six outcomes apart', () => {
|
|
26
|
+
const cases: [PipelineStep, string][] = [
|
|
27
|
+
[
|
|
28
|
+
step({ state: 'pending', stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
|
|
29
|
+
'not-started',
|
|
30
|
+
],
|
|
31
|
+
[step({ stepOptions: { binaryOutput: { storageServiceId: 'files' } } }), 'configured'],
|
|
32
|
+
[step({ binaryOutputs: report({ undeclared: true }) }), 'undeclared'],
|
|
33
|
+
[step({ binaryOutputs: report({ parseFailed: true }) }), 'parse-failed'],
|
|
34
|
+
[step({ binaryOutputs: report() }), 'declared-none'],
|
|
35
|
+
[step({ binaryOutputs: report({ stored: [artifact('files', 'a/b.png')] }) }), 'stored'],
|
|
36
|
+
]
|
|
37
|
+
for (const [input, expected] of cases) expect(binaryOutputView(input)?.state).toBe(expected)
|
|
38
|
+
// Every state has its own copy, so no two rows can read identically.
|
|
39
|
+
const summaries = Object.values(BINARY_OUTPUT_STATE_KEYS).map((k) => k.summary)
|
|
40
|
+
expect(new Set(summaries).size).toBe(summaries.length)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Absence is the one thing that must NOT render: a step with neither a report nor a
|
|
44
|
+
// selection had no binary-output story at all, which is every step of every stock pipeline.
|
|
45
|
+
it('renders nothing for a step that was never briefed', () => {
|
|
46
|
+
expect(binaryOutputView(step({}))).toBeNull()
|
|
47
|
+
expect(binaryOutputView(null)).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// A queued step has not had the chance to record anything, which `configured` ("running, or it
|
|
51
|
+
// died") states as the opposite of what is true. It still renders — unlike a SKIPPED step it
|
|
52
|
+
// has a story ahead of it, and where the artifacts will land is worth saying in advance.
|
|
53
|
+
it('separates a step that has not started from one that started and recorded nothing', () => {
|
|
54
|
+
const config = { binaryOutput: { storageServiceId: 'files' } }
|
|
55
|
+
const queued = binaryOutputView(step({ state: 'pending', stepOptions: config }))
|
|
56
|
+
expect(queued?.state).toBe('not-started')
|
|
57
|
+
expect(queued?.target).toBe('files')
|
|
58
|
+
// Nothing has gone wrong yet, so the section must not open itself expanded.
|
|
59
|
+
expect(binaryOutputHasWarnings(queued!)).toBe(false)
|
|
60
|
+
|
|
61
|
+
for (const state of ['working', 'waiting_decision', 'done'] as const)
|
|
62
|
+
expect(binaryOutputView(step({ state, stepOptions: config }))?.state).toBe('configured')
|
|
63
|
+
|
|
64
|
+
// A recorded claim still wins over either, exactly as it does for a skipped step.
|
|
65
|
+
expect(
|
|
66
|
+
binaryOutputView(
|
|
67
|
+
step({
|
|
68
|
+
state: 'pending',
|
|
69
|
+
stepOptions: config,
|
|
70
|
+
binaryOutputs: report({ undeclared: true }),
|
|
71
|
+
}),
|
|
72
|
+
)?.state,
|
|
73
|
+
).toBe('undeclared')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// A gated-out step holds a selection it never ran with, so `configured` would tell a reader it
|
|
77
|
+
// is still running or died mid-generation — both wrong, about a step already marked skipped.
|
|
78
|
+
it('renders nothing for a step skipped by estimate gating', () => {
|
|
79
|
+
expect(
|
|
80
|
+
binaryOutputView(
|
|
81
|
+
step({ skipped: true, stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
|
|
82
|
+
),
|
|
83
|
+
).toBeNull()
|
|
84
|
+
// A recorded claim is never hidden, whatever else the step says about itself.
|
|
85
|
+
expect(
|
|
86
|
+
binaryOutputView(
|
|
87
|
+
step({
|
|
88
|
+
skipped: true,
|
|
89
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
90
|
+
binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
|
|
91
|
+
}),
|
|
92
|
+
)?.state,
|
|
93
|
+
).toBe('stored')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// A parse failure implies an empty `stored`, so reading the list first would report it as
|
|
97
|
+
// "the agent said it stored nothing" — the one misreading with a completely wrong remedy.
|
|
98
|
+
it('reports an unreadable declaration as parse-failed, not as declared-none', () => {
|
|
99
|
+
const view = binaryOutputView(step({ binaryOutputs: report({ parseFailed: true }) }))
|
|
100
|
+
expect(view?.state).toBe('parse-failed')
|
|
101
|
+
expect(view?.rows).toHaveLength(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('names unknown services and keeps their rows', () => {
|
|
105
|
+
const view = binaryOutputView(
|
|
106
|
+
step({
|
|
107
|
+
binaryOutputs: report({
|
|
108
|
+
stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
|
|
109
|
+
unknownServices: ['ghost'],
|
|
110
|
+
}),
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
// The claim is recorded, not dropped — a reader judges it.
|
|
114
|
+
expect(view?.rows).toHaveLength(2)
|
|
115
|
+
expect(view?.unknownDeclaredServices).toEqual(['ghost'])
|
|
116
|
+
expect(view?.rows[1]?.unknown).toBe(true)
|
|
117
|
+
expect(view?.rows[0]?.unknown).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// The join the report cannot make alone, and the question a human actually opens this for.
|
|
121
|
+
it('marks a row stored through a service other than the configured target', () => {
|
|
122
|
+
const view = binaryOutputView(
|
|
123
|
+
step({
|
|
124
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
125
|
+
binaryOutputs: report({ stored: [artifact('files', 'a.png'), artifact('audit', 'b.png')] }),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
expect(view?.target).toBe('files')
|
|
129
|
+
expect(view?.rows.map((r) => r.misdirected)).toEqual([false, true])
|
|
130
|
+
expect(view?.misdirected).toBe(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// A step that never held a selection (a trait-carrying kind dispatched under an overriding
|
|
134
|
+
// kind) has nothing to compare against — so nothing may be reported as having gone astray.
|
|
135
|
+
it('marks nothing misdirected when the step carries no selection', () => {
|
|
136
|
+
const view = binaryOutputView(
|
|
137
|
+
step({ binaryOutputs: report({ stored: [artifact('audit', 'b.png')] }) }),
|
|
138
|
+
)
|
|
139
|
+
expect(view?.target).toBeNull()
|
|
140
|
+
expect(view?.misdirected).toBe(0)
|
|
141
|
+
expect(view?.rows[0]?.misdirected).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// "The catalog lost the step's own target" and "the agent named a service that never
|
|
145
|
+
// existed" are the same `unknownServices` entry with opposite fixes.
|
|
146
|
+
it('distinguishes a lost target from an invented service id', () => {
|
|
147
|
+
const lost = binaryOutputView(
|
|
148
|
+
step({
|
|
149
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
150
|
+
binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
expect(lost?.targetUnknown).toBe(true)
|
|
154
|
+
|
|
155
|
+
expect(lost?.unknownDeclaredServices).toEqual([])
|
|
156
|
+
|
|
157
|
+
const invented = binaryOutputView(
|
|
158
|
+
step({
|
|
159
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
160
|
+
binaryOutputs: report({ stored: [artifact('flies', 'a.png')], unknownServices: ['flies'] }),
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
expect(invented?.targetUnknown).toBe(false)
|
|
164
|
+
expect(invented?.unknownDeclaredServices).toEqual(['flies'])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Both at once is where sharing one field went wrong: the report's own `unknownServices` mixes
|
|
168
|
+
// the lost target with the invented ids, so a surface reading it raw named ALL of them as "this
|
|
169
|
+
// step's own storage service" and dropped the invented ones entirely. The two fields are
|
|
170
|
+
// disjoint by construction, so no renderer can restate one as the other.
|
|
171
|
+
it('keeps a lost target out of the invented-id list when both happened', () => {
|
|
172
|
+
const view = binaryOutputView(
|
|
173
|
+
step({
|
|
174
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
175
|
+
binaryOutputs: report({
|
|
176
|
+
stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
|
|
177
|
+
unknownServices: ['files', 'ghost', 'phantom'],
|
|
178
|
+
}),
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
expect(view?.targetUnknown).toBe(true)
|
|
182
|
+
expect(view?.unknownDeclaredServices).toEqual(['ghost', 'phantom'])
|
|
183
|
+
expect(view?.unknownDeclaredServices).not.toContain('files')
|
|
184
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// A lost target with nothing else unknown is still a warning — it is the whole comparison the
|
|
188
|
+
// surface exists to make, and the list it used to be counted in is now empty.
|
|
189
|
+
it('treats a lost target alone as a warning', () => {
|
|
190
|
+
const view = binaryOutputView(
|
|
191
|
+
step({
|
|
192
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
193
|
+
binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
expect(view?.unknownDeclaredServices).toEqual([])
|
|
197
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Without the count, a capped list reads as the whole list and its tail as nonexistent.
|
|
201
|
+
it('carries the counted losses through verbatim', () => {
|
|
202
|
+
const view = binaryOutputView(
|
|
203
|
+
step({
|
|
204
|
+
binaryOutputs: report({ stored: [artifact('files', 'a')], invalidEntries: 2, omitted: 7 }),
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
expect(view?.invalidEntries).toBe(2)
|
|
208
|
+
expect(view?.omitted).toBe(7)
|
|
209
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('treats a clean stored report as warning-free', () => {
|
|
213
|
+
const view = binaryOutputView(
|
|
214
|
+
step({
|
|
215
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files' } },
|
|
216
|
+
binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
expect(binaryOutputHasWarnings(view!)).toBe(false)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('binaryOutputPickIssues', () => {
|
|
224
|
+
const service = (id: string, capabilities: string[]) => ({ id, capabilities })
|
|
225
|
+
const catalog = [
|
|
226
|
+
service('files', ['asset-storage']),
|
|
227
|
+
service('inventory', ['generation-context']),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
it('accepts a selection that resolves against the catalog', () => {
|
|
231
|
+
const pick = binaryOutputPickIssues(
|
|
232
|
+
{ storageServiceId: 'files', contextServiceIds: ['inventory'] },
|
|
233
|
+
catalog,
|
|
234
|
+
true,
|
|
235
|
+
)
|
|
236
|
+
expect(pick.issues).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('flags a step with no storage selection', () => {
|
|
240
|
+
expect(binaryOutputPickIssues(undefined, catalog, true).issues).toContain('not_selected')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// The two refusals run admission raises, surfaced before the round trip rather than after it.
|
|
244
|
+
it('mirrors the admission refusals for a stale or untagged storage id', () => {
|
|
245
|
+
expect(binaryOutputPickIssues({ storageServiceId: 'gone' }, catalog, true).issues).toContain(
|
|
246
|
+
'unknown_service',
|
|
247
|
+
)
|
|
248
|
+
expect(
|
|
249
|
+
binaryOutputPickIssues({ storageServiceId: 'inventory' }, catalog, true).issues,
|
|
250
|
+
).toContain('not_storage_capable')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// "Pick another" is not a remedy when there is nothing to pick: with no storage service in the
|
|
254
|
+
// catalog at all, the per-selection judgements would print an instruction the surface cannot
|
|
255
|
+
// carry out, beside the one that is actionable. The context half is a different selection,
|
|
256
|
+
// judged on existence alone, so it stays.
|
|
257
|
+
it('suppresses the per-selection storage judgements when the catalog has no storage service', () => {
|
|
258
|
+
const contextOnly = [service('inventory', ['generation-context'])]
|
|
259
|
+
const pick = binaryOutputPickIssues(
|
|
260
|
+
{ storageServiceId: 'gone', contextServiceIds: ['inventory', 'vanished'] },
|
|
261
|
+
contextOnly,
|
|
262
|
+
true,
|
|
263
|
+
)
|
|
264
|
+
expect(pick.issues).toContain('no_storage_service')
|
|
265
|
+
expect(pick.issues).not.toContain('unknown_service')
|
|
266
|
+
expect(pick.issues).not.toContain('not_storage_capable')
|
|
267
|
+
expect(pick.issues).toContain('unknown_context_service')
|
|
268
|
+
expect(pick.unknownContextIds).toEqual(['vanished'])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('names every unresolved context id, not just the first', () => {
|
|
272
|
+
const pick = binaryOutputPickIssues(
|
|
273
|
+
{ storageServiceId: 'files', contextServiceIds: ['inventory', 'gone', 'also-gone'] },
|
|
274
|
+
catalog,
|
|
275
|
+
true,
|
|
276
|
+
)
|
|
277
|
+
expect(pick.issues).toContain('unknown_context_service')
|
|
278
|
+
expect(pick.unknownContextIds).toEqual(['gone', 'also-gone'])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// An empty picker reads as "no services exist", which is a claim. Not-probed-yet, unreachable
|
|
282
|
+
// and genuinely empty are three facts an empty array cannot tell apart.
|
|
283
|
+
it('separates an unreachable and an unprobed catalog from an empty one', () => {
|
|
284
|
+
expect(binaryOutputPickIssues(undefined, [], true).issues).toContain('no_storage_service')
|
|
285
|
+
expect(binaryOutputPickIssues(undefined, [], false).issues).toEqual([
|
|
286
|
+
'catalog_unavailable',
|
|
287
|
+
'not_selected',
|
|
288
|
+
])
|
|
289
|
+
// Before the probe lands there is nothing to say about the catalog — only about the step.
|
|
290
|
+
expect(binaryOutputPickIssues(undefined, [], null).issues).toEqual(['not_selected'])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// An outage (or a load still in flight) changed nothing about the selection, so neither may
|
|
294
|
+
// flag every step for re-pick.
|
|
295
|
+
it('does not judge a selection against a catalog it has not read', () => {
|
|
296
|
+
for (const available of [false, null] as const) {
|
|
297
|
+
const pick = binaryOutputPickIssues(
|
|
298
|
+
{ storageServiceId: 'files', contextServiceIds: ['inventory'] },
|
|
299
|
+
[],
|
|
300
|
+
available,
|
|
301
|
+
)
|
|
302
|
+
expect(pick.issues).not.toContain('unknown_service')
|
|
303
|
+
expect(pick.issues).not.toContain('unknown_context_service')
|
|
304
|
+
expect(pick.unknownContextIds).toEqual([])
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
})
|