@cat-factory/app 0.300.1 → 0.301.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 +21 -0
- package/app/components/assistant/AssistantModal.logic.spec.ts +129 -1
- package/app/components/assistant/AssistantModal.logic.ts +83 -1
- package/app/components/assistant/AssistantModal.vue +124 -17
- package/app/components/board/AddTaskModal.vue +1 -2
- package/app/components/board/CreateInitiativeModal.vue +2 -3
- package/app/components/board/RecurringPipelineModal.vue +1 -2
- package/app/components/documents/DocumentSourceConnectModal.vue +2 -2
- package/app/components/documents/StartFromDesignModal.vue +1 -2
- package/app/components/layout/CommandBar.vue +1 -2
- package/app/components/panels/inspector/ServiceTestingContext.logic.spec.ts +38 -0
- package/app/components/panels/inspector/ServiceTestingContext.logic.ts +37 -0
- package/app/components/panels/inspector/ServiceTestingContext.vue +141 -0
- package/app/components/pipeline/PipelineBuilder.vue +6 -6
- package/app/components/providers/PersonalCredentialModal.vue +3 -3
- package/app/components/tasks/BugHuntModal.vue +1 -2
- package/app/components/tasks/TaskImportModal.vue +5 -7
- package/app/components/tasks/TaskSourceConnectModal.vue +2 -2
- package/app/composables/api/assistant.ts +5 -3
- package/app/composables/useArtifactBlobs.ts +2 -1
- package/app/composables/useModalOpen.spec.ts +51 -0
- package/app/composables/useModalOpen.ts +31 -0
- package/app/modular/panels/inspector.logic.spec.ts +3 -0
- package/app/modular/panels/inspector.logic.ts +5 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/assistant.spec.ts +183 -0
- package/app/stores/assistant.ts +83 -7
- package/app/types/domain.ts +4 -0
- package/app/types/load-state.ts +18 -0
- package/app/utils/catalog.ts +21 -0
- package/i18n/locales/de.json +14 -0
- package/i18n/locales/en.json +23 -0
- package/i18n/locales/es.json +14 -0
- package/i18n/locales/fr.json +14 -0
- package/i18n/locales/he.json +14 -0
- package/i18n/locales/it.json +14 -0
- package/i18n/locales/ja.json +14 -0
- package/i18n/locales/pl.json +14 -0
- package/i18n/locales/tr.json +14 -0
- package/i18n/locales/uk.json +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { TESTING_CONTEXT_MAX_LENGTH } from '@cat-factory/contracts'
|
|
4
|
+
import type { Block } from '~/types/domain'
|
|
5
|
+
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
6
|
+
import { rehydratedDraft } from '~/components/panels/inspector/ServiceTestingContext.logic'
|
|
7
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
8
|
+
|
|
9
|
+
// Per-service (frame) TESTING CONTEXT: freeform prose about how this service is tested, which
|
|
10
|
+
// the engine injects verbatim into every tester prompt for it: the pipeline testers and the
|
|
11
|
+
// environment dry run's prober alike. It sits beside the sealed test credentials because the
|
|
12
|
+
// two are halves of one answer: the credentials are the material, this is what to do with it.
|
|
13
|
+
//
|
|
14
|
+
// Non-sensitive by contract: it is rendered INTO the prompt, so a real secret belongs one panel
|
|
15
|
+
// up, in the sealed store, and this prose refers to it by variable name. The banner says so.
|
|
16
|
+
//
|
|
17
|
+
// It is a plain block field (like the provisioning config), so it saves through the board store's
|
|
18
|
+
// `updateBlock` rather than a store of its own, and the draft below is what makes it an explicit
|
|
19
|
+
// save instead of a keystroke-per-request.
|
|
20
|
+
const props = defineProps<{ block: Block }>()
|
|
21
|
+
|
|
22
|
+
const board = useBoardStore()
|
|
23
|
+
const uiMode = useUiModeStore()
|
|
24
|
+
const toast = useToast()
|
|
25
|
+
const { t } = useI18n()
|
|
26
|
+
|
|
27
|
+
const busy = ref(false)
|
|
28
|
+
const draft = ref(props.block.testingContext ?? '')
|
|
29
|
+
|
|
30
|
+
// Re-hydrate from the block when the persisted value moves, but never over what the operator has
|
|
31
|
+
// typed (`rehydratedDraft` owns the rule and its spec states each case). The board store patches
|
|
32
|
+
// optimistically and ROLLS BACK on a rejected write, so a failed save arrives here looking exactly
|
|
33
|
+
// like a teammate's edit; taking it would erase the prose the toast is telling the operator to try
|
|
34
|
+
// saving again.
|
|
35
|
+
watch(
|
|
36
|
+
() => props.block.testingContext ?? '',
|
|
37
|
+
(incoming, previous) => {
|
|
38
|
+
draft.value = rehydratedDraft({ draft: draft.value, previous, incoming, saving: busy.value })
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const saved = computed(() => props.block.testingContext ?? '')
|
|
43
|
+
// What a save would SEND, which is what the server would store: the request trims before it caps,
|
|
44
|
+
// so trailing whitespace is neither length spent nor a change worth a request.
|
|
45
|
+
const outgoing = computed(() => draft.value.trim())
|
|
46
|
+
const tooLong = computed(() => outgoing.value.length > TESTING_CONTEXT_MAX_LENGTH)
|
|
47
|
+
const dirty = computed(() => outgoing.value !== saved.value)
|
|
48
|
+
const canSave = computed(() => !busy.value && dirty.value && !tooLong.value)
|
|
49
|
+
|
|
50
|
+
// Standing per-service configuration, not part of the everyday delivery loop: a service is briefed
|
|
51
|
+
// once and every task then ships without anyone opening this. Absent, every tester prompt is
|
|
52
|
+
// byte-identical to one written before the field existed, which is what makes hiding it honest at
|
|
53
|
+
// the basic tier. `showOverrideField` (not a bare `isAdvanced`) because a service that HAS been
|
|
54
|
+
// briefed must show its prose to whoever opens the inspector: nothing else in the SPA surfaces
|
|
55
|
+
// what the testers are being told, so hiding a filled box would leave a basic-tier user unable to
|
|
56
|
+
// read, correct or clear it. The ROLE axis needs no separate answer: `intake` is capped at basic
|
|
57
|
+
// and never configures the platform, so this reaches the same people the tier bar admits.
|
|
58
|
+
const show = computed(() => showOverrideField(uiMode.isAdvanced, saved.value))
|
|
59
|
+
|
|
60
|
+
async function save() {
|
|
61
|
+
busy.value = true
|
|
62
|
+
try {
|
|
63
|
+
// `updateBlock` reports its own failure (it rolls back and toasts), so only the success
|
|
64
|
+
// needs saying here; announcing it unconditionally would claim a save the rollback undid.
|
|
65
|
+
const persisted = await board.updateBlock(props.block.id, { testingContext: outgoing.value })
|
|
66
|
+
if (persisted) {
|
|
67
|
+
toast.add({
|
|
68
|
+
title: t('inspector.testingContext.savedToast'),
|
|
69
|
+
icon: 'i-lucide-check',
|
|
70
|
+
color: 'success',
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
busy.value = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function revert() {
|
|
79
|
+
draft.value = saved.value
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<InspectorSection
|
|
85
|
+
v-if="show"
|
|
86
|
+
:title="t('inspector.testingContext.title')"
|
|
87
|
+
:hint="t('inspector.testingContext.sectionHint')"
|
|
88
|
+
data-testid="service-testing-context"
|
|
89
|
+
>
|
|
90
|
+
<!-- This text reaches the model in the prompt, so it must never hold a real secret. -->
|
|
91
|
+
<div
|
|
92
|
+
class="flex items-start gap-2 rounded-md border border-slate-700 bg-slate-800/40 px-2.5 py-2 text-[11px] leading-snug text-slate-300"
|
|
93
|
+
>
|
|
94
|
+
<UIcon name="i-lucide-info" class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
|
95
|
+
<span>{{ t('inspector.testingContext.notSecret') }}</span>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<UTextarea
|
|
99
|
+
v-model="draft"
|
|
100
|
+
:rows="8"
|
|
101
|
+
:placeholder="t('inspector.testingContext.placeholder')"
|
|
102
|
+
class="w-full"
|
|
103
|
+
data-testid="testing-context-input"
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
<div class="flex items-center justify-between gap-2">
|
|
107
|
+
<p class="text-[11px] text-slate-500" :class="{ 'text-error-400': tooLong }">
|
|
108
|
+
{{
|
|
109
|
+
t('inspector.testingContext.length', {
|
|
110
|
+
count: outgoing.length,
|
|
111
|
+
max: TESTING_CONTEXT_MAX_LENGTH,
|
|
112
|
+
})
|
|
113
|
+
}}
|
|
114
|
+
</p>
|
|
115
|
+
<div class="flex items-center gap-2">
|
|
116
|
+
<UButton
|
|
117
|
+
v-if="dirty"
|
|
118
|
+
color="neutral"
|
|
119
|
+
variant="ghost"
|
|
120
|
+
size="xs"
|
|
121
|
+
data-testid="testing-context-revert"
|
|
122
|
+
@click="revert"
|
|
123
|
+
>
|
|
124
|
+
{{ t('inspector.testingContext.revert') }}
|
|
125
|
+
</UButton>
|
|
126
|
+
<UButton
|
|
127
|
+
color="primary"
|
|
128
|
+
variant="soft"
|
|
129
|
+
size="xs"
|
|
130
|
+
icon="i-lucide-save"
|
|
131
|
+
:loading="busy"
|
|
132
|
+
:disabled="!canSave"
|
|
133
|
+
data-testid="testing-context-save"
|
|
134
|
+
@click="save"
|
|
135
|
+
>
|
|
136
|
+
{{ t('inspector.testingContext.save') }}
|
|
137
|
+
</UButton>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</InspectorSection>
|
|
141
|
+
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
3
|
import { DEPLOYER_AGENT_KIND } from '@cat-factory/contracts'
|
|
4
4
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
@@ -247,20 +247,20 @@ const open = computed({
|
|
|
247
247
|
// Refresh the observability-integration state whenever the builder opens so the palette
|
|
248
248
|
// knows whether to offer the post-release-health gate (it's loaded on demand, not from
|
|
249
249
|
// the snapshot). Best-effort: a failure just leaves the gate hidden.
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
onModalOpen(open, () => {
|
|
251
|
+
releaseHealth.load().catch(() => {})
|
|
252
252
|
// The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
|
|
253
253
|
// Best-effort: the builder is fully usable without it, and a deployment that wires no
|
|
254
254
|
// override store answers 503 here.
|
|
255
|
-
|
|
255
|
+
agentPrompts.loadIndex().catch(() => {})
|
|
256
256
|
// The workspace's per-kind output ceilings, which the per-step field shows as its inherited
|
|
257
257
|
// placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
|
|
258
|
-
|
|
258
|
+
agentSettings.load().catch(() => {})
|
|
259
259
|
// The resolved foundational-services catalog, which the binary-output picker offers from.
|
|
260
260
|
// Single-flighted per workspace, so this shares the panel's load rather than adding one. A
|
|
261
261
|
// failure is not swallowed into an empty picker: the store records `available: false`, and
|
|
262
262
|
// the picker says the catalog is unreachable rather than "no services exist".
|
|
263
|
-
|
|
263
|
+
void foundational.ensureProbed()
|
|
264
264
|
})
|
|
265
265
|
|
|
266
266
|
function add(kind: AgentKind) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// personalSubscriptions store's `pending` state, which is set when the server replies 428
|
|
5
5
|
// credential_required. On submit it transparently retries the gated action and caches the
|
|
6
6
|
// password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
|
|
7
|
-
import { computed, ref
|
|
7
|
+
import { computed, ref } from 'vue'
|
|
8
8
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
9
9
|
|
|
10
10
|
const { t } = useI18n()
|
|
@@ -45,8 +45,8 @@ const vendorLabel = computed(() => {
|
|
|
45
45
|
}
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
onModalOpen(open, () => {
|
|
49
|
+
password.value = ''
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
const title = computed(() => {
|
|
@@ -70,13 +70,11 @@ const title = computed(() =>
|
|
|
70
70
|
pinnedContainer.value ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
tasks.loadTasks().catch(() => {})
|
|
79
|
-
}
|
|
73
|
+
onModalOpen(open, () => {
|
|
74
|
+
ref_.value = ''
|
|
75
|
+
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
76
|
+
resetContainer()
|
|
77
|
+
tasks.loadTasks().catch(() => {})
|
|
80
78
|
})
|
|
81
79
|
|
|
82
80
|
// Choosing an issue in the picker hands off to the add-task form, prefilled with the
|
|
@@ -45,8 +45,8 @@ const values = ref<Record<string, string>>({})
|
|
|
45
45
|
const saving = ref(false)
|
|
46
46
|
const togglingEnabled = ref(false)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
onModalOpen(open, () => {
|
|
49
|
+
values.value = {}
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
const canSubmit = computed(() => {
|
|
@@ -6,9 +6,11 @@ import type { ApiContext } from './context'
|
|
|
6
6
|
export function assistantApi({ send, ws }: ApiContext) {
|
|
7
7
|
return {
|
|
8
8
|
// Whether a model is wired and which actions this deployment offers. Read before the prompt
|
|
9
|
-
// box is shown, so an unconfigured deployment says so instead of failing on submit.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// box is shown, so an unconfigured deployment says so instead of failing on submit. `signal`
|
|
10
|
+
// is what lets the store put a deadline on it: the client sets no timeout of its own, and a
|
|
11
|
+
// read that never settles is a modal with no answer, no failure and so no retry either.
|
|
12
|
+
getAssistantCapability: (workspaceId: string, signal?: AbortSignal) =>
|
|
13
|
+
send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId), signal }),
|
|
12
14
|
|
|
13
15
|
// Run one turn. A live model call plus a board write, so it can take a couple of seconds:
|
|
14
16
|
// the modal shows progress and the outcome is rendered from the returned data.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { reactive } from 'vue'
|
|
2
|
+
import type { LoadState } from '~/types/load-state'
|
|
2
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -15,7 +16,7 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
15
16
|
* Both the visual-confirmation gate and the test-report window use this, so neither has to
|
|
16
17
|
* own blob plumbing or depend on the other's Pinia store.
|
|
17
18
|
*/
|
|
18
|
-
export type ArtifactBlobStatus =
|
|
19
|
+
export type ArtifactBlobStatus = LoadState
|
|
19
20
|
|
|
20
21
|
export function useArtifactBlobs() {
|
|
21
22
|
const ws = useWorkspaceStore()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { effectScope, nextTick, ref } from 'vue'
|
|
3
|
+
import { onModalOpen } from './useModalOpen'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register `onModalOpen` with `open` already at `mountedOpen`, which is what a modal's setup does.
|
|
7
|
+
* That first value is the whole point: the page mounts most panels only WHILE their flag is set,
|
|
8
|
+
* so a modal's `open` is already `true` by the time its own setup runs.
|
|
9
|
+
*/
|
|
10
|
+
function register(mountedOpen: boolean) {
|
|
11
|
+
const open = ref(mountedOpen)
|
|
12
|
+
const ran = vi.fn()
|
|
13
|
+
const scope = effectScope()
|
|
14
|
+
scope.run(() => onModalOpen(open, ran))
|
|
15
|
+
return { open, ran, stop: () => scope.stop() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('onModalOpen', () => {
|
|
19
|
+
it('runs on the render the panel MOUNTS on, when it mounts already open', () => {
|
|
20
|
+
// The bug this exists to make unrepresentable: a change-only `watch(open)` never fires for a
|
|
21
|
+
// `v-if`-mounted modal, so whatever the body seeds (a picker's default, the read that fills a
|
|
22
|
+
// catalog) never happens and the panel opens with an unselected control and a dead confirm.
|
|
23
|
+
const { ran, stop } = register(true)
|
|
24
|
+
|
|
25
|
+
expect(ran).toHaveBeenCalledTimes(1)
|
|
26
|
+
stop()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not run for a panel that is mounted CLOSED', () => {
|
|
30
|
+
// The always-mounted half of the SPA. The same call has to be correct there, or this could not
|
|
31
|
+
// be the one thing every site uses and each author would be back to deciding per site.
|
|
32
|
+
const { ran, stop } = register(false)
|
|
33
|
+
|
|
34
|
+
expect(ran).not.toHaveBeenCalled()
|
|
35
|
+
stop()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('runs again on each later open, and never on a close', async () => {
|
|
39
|
+
const { open, ran, stop } = register(false)
|
|
40
|
+
|
|
41
|
+
open.value = true
|
|
42
|
+
await nextTick()
|
|
43
|
+
open.value = false
|
|
44
|
+
await nextTick()
|
|
45
|
+
open.value = true
|
|
46
|
+
await nextTick()
|
|
47
|
+
|
|
48
|
+
expect(ran).toHaveBeenCalledTimes(2)
|
|
49
|
+
stop()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import type { WatchSource } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run `fn` whenever a modal opens, INCLUDING the render it mounts on.
|
|
6
|
+
*
|
|
7
|
+
* This exists because `watch(open, (isOpen) => { if (isOpen) … })` is wrong in this SPA and reads
|
|
8
|
+
* as right. The page mounts most panels only WHILE their open flag is set
|
|
9
|
+
* (`<AssistantModal v-if="ui.assistantOpen" />`), so `open` is already `true` when the component
|
|
10
|
+
* sets up and a change-only watcher never fires at all: whatever the body seeds (a picker's
|
|
11
|
+
* default, the read that fills a catalog) never happens, and the panel opens with an unselected
|
|
12
|
+
* control and a confirm button that cannot be pressed. Nothing throws, which is why it survived in
|
|
13
|
+
* several modals at once.
|
|
14
|
+
*
|
|
15
|
+
* `{ immediate: true }` is the whole fix, and a shared helper is how it stops being per-site
|
|
16
|
+
* knowledge: a panel that IS always mounted passes a `false` on that first run and the body is
|
|
17
|
+
* skipped, so this is correct either way and nobody has to know which kind they are writing.
|
|
18
|
+
*
|
|
19
|
+
* Only for work done on the way IN. A watcher acting on the CLOSE edge (emitting `close`, revoking
|
|
20
|
+
* object URLs) stays a plain `watch`: it has nothing to do at mount, and running it there would
|
|
21
|
+
* announce a close that never happened.
|
|
22
|
+
*/
|
|
23
|
+
export function onModalOpen(open: WatchSource<boolean>, fn: () => void): void {
|
|
24
|
+
watch(
|
|
25
|
+
open,
|
|
26
|
+
(isOpen) => {
|
|
27
|
+
if (isOpen) fn()
|
|
28
|
+
},
|
|
29
|
+
{ immediate: true },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -39,6 +39,7 @@ describe('inspector panel group', () => {
|
|
|
39
39
|
'service-connections',
|
|
40
40
|
'service-test-config',
|
|
41
41
|
'service-test-secrets',
|
|
42
|
+
'service-testing-context',
|
|
42
43
|
'service-fragments',
|
|
43
44
|
'service-release-health',
|
|
44
45
|
'service-validation-checks',
|
|
@@ -58,6 +59,7 @@ describe('inspector panel group', () => {
|
|
|
58
59
|
'container-summary',
|
|
59
60
|
'service-test-config',
|
|
60
61
|
'service-test-secrets',
|
|
62
|
+
'service-testing-context',
|
|
61
63
|
'service-fragments',
|
|
62
64
|
'service-release-health',
|
|
63
65
|
'service-validation-checks',
|
|
@@ -70,6 +72,7 @@ describe('inspector panel group', () => {
|
|
|
70
72
|
'frontend-config',
|
|
71
73
|
'service-test-config',
|
|
72
74
|
'service-test-secrets',
|
|
75
|
+
'service-testing-context',
|
|
73
76
|
'service-fragments',
|
|
74
77
|
'service-release-health',
|
|
75
78
|
'service-validation-checks',
|
|
@@ -53,6 +53,7 @@ export const INSPECTOR_PANEL_IDS = [
|
|
|
53
53
|
'service-connections',
|
|
54
54
|
'service-test-config',
|
|
55
55
|
'service-test-secrets',
|
|
56
|
+
'service-testing-context',
|
|
56
57
|
'service-fragments',
|
|
57
58
|
'service-release-health',
|
|
58
59
|
'service-validation-checks',
|
|
@@ -146,6 +147,10 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
|
146
147
|
{ id: 'service-connections', order: 130, when: (b) => isFrame(b) && b.type === 'service' },
|
|
147
148
|
{ id: 'service-test-config', order: 140, when: isDeployableFrame },
|
|
148
149
|
{ id: 'service-test-secrets', order: 150, when: isDeployableFrame },
|
|
150
|
+
// Immediately after the credentials: the prose that says what to DO with them, and the other
|
|
151
|
+
// half of what a tester is handed about this service. Same gate for the same reason (a doc
|
|
152
|
+
// repo runs no tester at all).
|
|
153
|
+
{ id: 'service-testing-context', order: 155, when: isDeployableFrame },
|
|
149
154
|
{ id: 'service-fragments', order: 160, when: isFrame },
|
|
150
155
|
{ id: 'service-release-health', order: 170, when: isDeployableFrame },
|
|
151
156
|
// Pre-PR validation checks: the commands the harness runs before opening this service's PRs.
|
|
@@ -28,6 +28,7 @@ import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
|
|
|
28
28
|
import ServiceConnections from '~/components/panels/inspector/ServiceConnections.vue'
|
|
29
29
|
import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
|
|
30
30
|
import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
|
|
31
|
+
import ServiceTestingContext from '~/components/panels/inspector/ServiceTestingContext.vue'
|
|
31
32
|
import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
|
|
32
33
|
import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
|
|
33
34
|
import ServiceValidationConfig from '~/components/panels/inspector/ServiceValidationConfig.vue'
|
|
@@ -83,6 +84,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
|
83
84
|
'service-connections': ServiceConnections,
|
|
84
85
|
'service-test-config': ServiceTestConfig,
|
|
85
86
|
'service-test-secrets': ServiceTestSecrets,
|
|
87
|
+
'service-testing-context': ServiceTestingContext,
|
|
86
88
|
'service-fragments': ServiceFragments,
|
|
87
89
|
'service-release-health': ServiceReleaseHealthConfig,
|
|
88
90
|
'service-validation-checks': ServiceValidationConfig,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { useAssistantStore } from '~/stores/assistant'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import { ApiError } from '~/composables/api/errors'
|
|
5
|
+
import type { AssistantCapability } from '~/types/domain'
|
|
6
|
+
|
|
7
|
+
// What the modal reads to decide whether it may offer the prompt box, and whether that box may be
|
|
8
|
+
// submitted. The capability's own ANSWER is two independent bits (is a model wired, and is there
|
|
9
|
+
// anything for it to do); on their own they cannot say whether anyone has asked yet, which is a
|
|
10
|
+
// third fact the modal has to render differently from both.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stub `useApi` ONCE, behind a handler the test can swap. The store resolves `useApi()` at setup,
|
|
14
|
+
* so re-stubbing after `useAssistantStore()` would leave it holding the first stub for ever.
|
|
15
|
+
*
|
|
16
|
+
* The handler is given the abort signal the store attaches its deadline to, so a test can assert
|
|
17
|
+
* on what a timed-out read does to the request as well as to the state.
|
|
18
|
+
*/
|
|
19
|
+
function stubApi(): {
|
|
20
|
+
store: ReturnType<typeof useAssistantStore>
|
|
21
|
+
serve: (fn: (signal: AbortSignal) => Promise<AssistantCapability>) => void
|
|
22
|
+
} {
|
|
23
|
+
let handler: (signal: AbortSignal) => Promise<AssistantCapability> = () =>
|
|
24
|
+
Promise.resolve({ available: true, actions: ['declare-service-dependency'] })
|
|
25
|
+
vi.stubGlobal('useApi', () => ({
|
|
26
|
+
getAssistantCapability: (_ws: string, signal: AbortSignal) => handler(signal),
|
|
27
|
+
}))
|
|
28
|
+
return {
|
|
29
|
+
store: useAssistantStore(),
|
|
30
|
+
serve: (fn) => {
|
|
31
|
+
handler = fn
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const failing = (status = 503) =>
|
|
37
|
+
Promise.reject(new ApiError(status, { error: { code: 'unavailable', message: 'nope' } }))
|
|
38
|
+
|
|
39
|
+
describe('assistant store: the capability read', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('starts idle, which is not the same fact as unavailable', () => {
|
|
45
|
+
const { store } = stubApi()
|
|
46
|
+
|
|
47
|
+
expect(store.capabilityRead).toBe('idle')
|
|
48
|
+
expect(store.capability).toBeNull()
|
|
49
|
+
expect(store.available).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('holds `loading` while the read is in flight', async () => {
|
|
53
|
+
const { store, serve } = stubApi()
|
|
54
|
+
let answer!: (capability: AssistantCapability) => void
|
|
55
|
+
serve(() => new Promise((resolve) => (answer = resolve)))
|
|
56
|
+
|
|
57
|
+
const inFlight = store.loadCapability()
|
|
58
|
+
expect(store.capabilityRead).toBe('loading')
|
|
59
|
+
|
|
60
|
+
answer({ available: true, actions: ['declare-service-dependency'] })
|
|
61
|
+
await inFlight
|
|
62
|
+
|
|
63
|
+
expect(store.capabilityRead).toBe('ready')
|
|
64
|
+
expect(store.actions).toEqual(['declare-service-dependency'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('KEEPS the answer it holds while a re-read is in flight', async () => {
|
|
68
|
+
// Re-opening the modal re-reads. Clearing the answer for the duration would replace the box
|
|
69
|
+
// with a waiting state on every open, for a fact the store can already state.
|
|
70
|
+
const { store, serve } = stubApi()
|
|
71
|
+
await store.loadCapability()
|
|
72
|
+
|
|
73
|
+
serve(() => new Promise(() => {}))
|
|
74
|
+
void store.loadCapability()
|
|
75
|
+
|
|
76
|
+
expect(store.capabilityRead).toBe('loading')
|
|
77
|
+
expect(store.available).toBe(true)
|
|
78
|
+
expect(store.actions).toEqual(['declare-service-dependency'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('records a read that answered "no model" as read, so it can be told from an outage', async () => {
|
|
82
|
+
const { store, serve } = stubApi()
|
|
83
|
+
serve(() => Promise.resolve({ available: false, actions: [] }))
|
|
84
|
+
|
|
85
|
+
await store.loadCapability()
|
|
86
|
+
|
|
87
|
+
expect(store.capabilityRead).toBe('ready')
|
|
88
|
+
expect(store.available).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('marks a FAILED read as failed and does not throw', async () => {
|
|
92
|
+
// The failure is REPORTED by the panel this state puts on screen, which is where the retry
|
|
93
|
+
// lives too. Throwing it as well would have the modal toast a second, non-dismissing copy of
|
|
94
|
+
// the same sentence over that panel, once per retry.
|
|
95
|
+
const { store, serve } = stubApi()
|
|
96
|
+
serve(() => failing())
|
|
97
|
+
|
|
98
|
+
await expect(store.loadCapability()).resolves.toBeUndefined()
|
|
99
|
+
|
|
100
|
+
expect(store.capabilityRead).toBe('error')
|
|
101
|
+
expect(store.capability).toBeNull()
|
|
102
|
+
expect(store.available).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('drops a previous answer when a later read fails', async () => {
|
|
106
|
+
// A retry that fails must not leave the box open on the strength of the read before it: the
|
|
107
|
+
// deployment's model may have gone away with whatever took the endpoint down.
|
|
108
|
+
const { store, serve } = stubApi()
|
|
109
|
+
serve(() => Promise.resolve({ available: true, actions: ['add-service-from-repo'] }))
|
|
110
|
+
await store.loadCapability()
|
|
111
|
+
|
|
112
|
+
serve(() => failing(500))
|
|
113
|
+
await store.loadCapability()
|
|
114
|
+
|
|
115
|
+
expect(store.capability).toBeNull()
|
|
116
|
+
expect(store.actions).toEqual([])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('recovers on a retry that answers', async () => {
|
|
120
|
+
const { store, serve } = stubApi()
|
|
121
|
+
serve(() => failing(502))
|
|
122
|
+
await store.loadCapability()
|
|
123
|
+
|
|
124
|
+
serve(() => Promise.resolve({ available: true, actions: ['create-task-from-issue'] }))
|
|
125
|
+
await store.loadCapability()
|
|
126
|
+
|
|
127
|
+
expect(store.capabilityRead).toBe('ready')
|
|
128
|
+
expect(store.available).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('settles overlapping reads in the order they STARTED, not the order they answer', async () => {
|
|
132
|
+
// A slow success that lands after the fast failure which superseded it would otherwise
|
|
133
|
+
// re-offer the box on an answer older than the failure that replaced it.
|
|
134
|
+
const { store, serve } = stubApi()
|
|
135
|
+
let answerSlow!: (capability: AssistantCapability) => void
|
|
136
|
+
serve(() => new Promise((resolve) => (answerSlow = resolve)))
|
|
137
|
+
const slow = store.loadCapability()
|
|
138
|
+
|
|
139
|
+
serve(() => failing())
|
|
140
|
+
await store.loadCapability()
|
|
141
|
+
expect(store.capabilityRead).toBe('error')
|
|
142
|
+
|
|
143
|
+
answerSlow({ available: true, actions: ['declare-service-dependency'] })
|
|
144
|
+
await slow
|
|
145
|
+
|
|
146
|
+
expect(store.capabilityRead).toBe('error')
|
|
147
|
+
expect(store.capability).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('assistant store: the capability read deadline', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
154
|
+
vi.useFakeTimers()
|
|
155
|
+
})
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
vi.useRealTimers()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('fails a read that never settles, and ABORTS the request it gave up on', async () => {
|
|
161
|
+
// The shared client sets no timeout, so a connection accepted and never answered would leave
|
|
162
|
+
// the modal waiting for ever: no answer, no failure, and so no retry either, since the retry
|
|
163
|
+
// is what a failed read puts on screen.
|
|
164
|
+
const { store, serve } = stubApi()
|
|
165
|
+
let aborted = false
|
|
166
|
+
serve(
|
|
167
|
+
(signal) =>
|
|
168
|
+
new Promise((_resolve, reject) => {
|
|
169
|
+
signal.addEventListener('abort', () => {
|
|
170
|
+
aborted = true
|
|
171
|
+
reject(new Error('aborted'))
|
|
172
|
+
})
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const read = store.loadCapability()
|
|
177
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
178
|
+
await read
|
|
179
|
+
|
|
180
|
+
expect(aborted).toBe(true)
|
|
181
|
+
expect(store.capabilityRead).toBe('error')
|
|
182
|
+
})
|
|
183
|
+
})
|