@cat-factory/app 0.110.3 → 0.111.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.
@@ -40,7 +40,7 @@ const { linkPending } = useContextLinking()
40
40
  const open = computed({
41
41
  get: () => ui.addTaskContainerId !== null,
42
42
  set: (v: boolean) => {
43
- if (!v) ui.closeAddTask()
43
+ if (!v) void requestClose()
44
44
  },
45
45
  })
46
46
 
@@ -405,6 +405,34 @@ watch(open, (isOpen) => {
405
405
  resolvePendingIssueBodies().catch(() => {})
406
406
  })
407
407
 
408
+ // UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
409
+ // after the reset watcher so the baseline is the seeded form (a prefill is the clean
410
+ // starting point, not a spurious edit). The snapshot covers the user-owned fields — the
411
+ // cheap task-type / technical toggles are excluded, and only the *stable* context keys are
412
+ // compared so the async issue-body resolution never reads as a change.
413
+ const { requestClose } = useUnsavedGuard({
414
+ open,
415
+ close: () => ui.closeAddTask(),
416
+ saving: () => saving.value,
417
+ snapshot: () => ({
418
+ title: title.value.trim(),
419
+ description: description.value.trim(),
420
+ severity: severity.value,
421
+ stepsToReproduce: stepsToReproduce.value.trim(),
422
+ timeboxHours: timeboxHours.value ?? null,
423
+ docKind: docKind.value,
424
+ docAudience: docAudience.value.trim(),
425
+ docTargetPath: docTargetPath.value.trim(),
426
+ docOutlineHints: docOutlineHints.value.trim(),
427
+ docKindFieldValues: { ...docKindFieldValues },
428
+ riskPolicyId: riskPolicyId.value,
429
+ modelPresetId: modelPresetId.value,
430
+ pipelineId: pipelineId.value,
431
+ agentConfig: { ...agentConfigValues.value },
432
+ context: pendingContext.value.map(contextKey),
433
+ }),
434
+ })
435
+
408
436
  // A recurring task only needs a target frame (its details are filled in the schedule
409
437
  // modal); every other type needs a title.
410
438
  const canAdd = computed(() =>
@@ -921,7 +949,7 @@ async function add() {
921
949
 
922
950
  <template #footer>
923
951
  <div class="flex w-full justify-end gap-2">
924
- <UButton color="neutral" variant="ghost" @click="ui.closeAddTask()">{{
952
+ <UButton color="neutral" variant="ghost" @click="requestClose()">{{
925
953
  t('common.cancel')
926
954
  }}</UButton>
927
955
  <UButton
@@ -21,7 +21,7 @@ const { t } = useI18n()
21
21
  const open = computed({
22
22
  get: () => ui.addRecurringFrameId !== null,
23
23
  set: (v: boolean) => {
24
- if (!v) ui.closeAddRecurring()
24
+ if (!v) void requestClose()
25
25
  },
26
26
  })
27
27
 
@@ -131,6 +131,33 @@ watch(open, (isOpen) => {
131
131
  void tasks.probe()
132
132
  })
133
133
 
134
+ // UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
135
+ // after the reset watcher so the baseline is the seeded form (the default pipeline + the
136
+ // workspace tracker settings are the clean starting point, not a spurious edit).
137
+ const { requestClose } = useUnsavedGuard({
138
+ open,
139
+ close: () => ui.closeAddRecurring(),
140
+ saving: () => saving.value,
141
+ snapshot: () => ({
142
+ name: name.value.trim(),
143
+ description: description.value.trim(),
144
+ pipelineId: pipelineId.value,
145
+ onDemand: onDemand.value,
146
+ recurrence: recurrence.value,
147
+ trackerKind: trackerKind.value,
148
+ jiraProjectKey: jiraProjectKey.value.trim(),
149
+ linearTeamId: linearTeamId.value.trim(),
150
+ intakeSource: intakeSource.value,
151
+ intakeJiraProjectKey: intakeJiraProjectKey.value.trim(),
152
+ intakeLinearTeamId: intakeLinearTeamId.value.trim(),
153
+ intakeGithubRepo: intakeGithubRepo.value.trim(),
154
+ intakeTitleFragment: intakeTitleFragment.value.trim(),
155
+ intakeLabels: intakeLabels.value.trim(),
156
+ intakeIssueType: intakeIssueType.value.trim(),
157
+ intakeInProgressLabel: intakeInProgressLabel.value.trim(),
158
+ }),
159
+ })
160
+
134
161
  // The board field required for the picked source must be filled before a bug-intake schedule saves.
135
162
  const intakeReady = computed(() => {
136
163
  if (!isBugIntake.value) return true
@@ -423,7 +450,7 @@ async function add() {
423
450
 
424
451
  <template #footer>
425
452
  <div class="flex w-full justify-end gap-2">
426
- <UButton color="neutral" variant="ghost" @click="ui.closeAddRecurring()">{{
453
+ <UButton color="neutral" variant="ghost" @click="requestClose()">{{
427
454
  t('common.cancel')
428
455
  }}</UButton>
429
456
  <UButton
@@ -22,7 +22,7 @@ const { confirmAction, toastDone } = useConfirmAction()
22
22
  const open = computed({
23
23
  get: () => ui.bootstrapOpen,
24
24
  set: (v: boolean) => {
25
- if (!v) ui.closeBootstrap()
25
+ if (!v) void requestClose()
26
26
  },
27
27
  })
28
28
 
@@ -84,6 +84,23 @@ const typeItems = useFrameRepoTypeItems()
84
84
 
85
85
  const usingReference = computed(() => mode.value === 'reference')
86
86
 
87
+ // UX-18: prompt before discarding a half-filled launch form on Escape / backdrop / the X.
88
+ // The modal keeps its fields across opens (no reset watcher), so the baseline is whatever
89
+ // the form held when it opened — a close only prompts once the user has typed something
90
+ // new. Only the typed launch fields are guarded; the reference-architecture sub-form has
91
+ // its own Cancel/Save. Declared here (below the launch-form refs) because the guard reads
92
+ // its initial baseline synchronously.
93
+ const { requestClose } = useUnsavedGuard({
94
+ open,
95
+ close: () => ui.closeBootstrap(),
96
+ saving: () => launching.value,
97
+ snapshot: () => ({
98
+ repoName: repoName.value.trim(),
99
+ description: description.value.trim(),
100
+ instructions: instructions.value.trim(),
101
+ }),
102
+ })
103
+
87
104
  // Mirror of the backend `slugField` rule (@cat-factory/contracts bootstrap
88
105
  // schema): the new repo name is a SINGLE GitHub name segment — no "owner/"
89
106
  // prefix — so reject a bad value inline before we hit the API. Kept in sync with
@@ -4,6 +4,7 @@ import { agentKindMeta } from '~/utils/catalog'
4
4
  const execution = useExecutionStore()
5
5
  const board = useBoardStore()
6
6
  const ui = useUiStore()
7
+ const toast = useToast()
7
8
  const { t } = useI18n()
8
9
 
9
10
  const ctx = computed(() => ui.decisionContext)
@@ -16,17 +17,42 @@ const decision = computed(() => step.value?.decision ?? null)
16
17
  const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
17
18
  const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
18
19
 
20
+ // UX-25: which option is being resolved (null = idle). Guards against a fire-and-forget
21
+ // double-submit — the resolve is awaited, all options disable while it runs, and a failed
22
+ // resolve keeps the modal open with an error toast instead of closing silently.
23
+ const resolvingOption = ref<string | null>(null)
24
+
19
25
  const open = computed({
20
26
  get: () => !!ctx.value && !!decision.value,
21
27
  set: (v: boolean) => {
22
- if (!v) ui.closeDecision()
28
+ // While a resolve is in flight the options are disabled, so the dismiss affordances
29
+ // (Escape / backdrop) are locked too — the awaited resolve settles the modal itself.
30
+ if (!v && !resolvingOption.value) ui.closeDecision()
23
31
  },
24
32
  })
25
33
 
26
- function choose(option: string) {
27
- if (!ctx.value) return
28
- execution.resolveDecision(ctx.value.instanceId, ctx.value.decisionId, option)
29
- ui.closeDecision()
34
+ async function choose(option: string) {
35
+ if (!ctx.value || resolvingOption.value) return
36
+ resolvingOption.value = option
37
+ try {
38
+ // `resolveDecision` returns false when a required-credential prompt is cancelled — keep
39
+ // the modal open in that case so the choice isn't silently dropped.
40
+ const resolved = await execution.resolveDecision(
41
+ ctx.value.instanceId,
42
+ ctx.value.decisionId,
43
+ option,
44
+ )
45
+ if (resolved) ui.closeDecision()
46
+ } catch (e) {
47
+ toast.add({
48
+ title: t('panels.decision.resolveFailed'),
49
+ description: e instanceof Error ? e.message : String(e),
50
+ icon: 'i-lucide-triangle-alert',
51
+ color: 'error',
52
+ })
53
+ } finally {
54
+ resolvingOption.value = null
55
+ }
30
56
  }
31
57
  </script>
32
58
 
@@ -65,6 +91,8 @@ function choose(option: string) {
65
91
  block
66
92
  data-testid="decision-option"
67
93
  class="justify-start"
94
+ :loading="resolvingOption === opt"
95
+ :disabled="resolvingOption !== null && resolvingOption !== opt"
68
96
  @click="choose(opt)"
69
97
  >
70
98
  {{ opt }}
@@ -8,6 +8,7 @@ import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
8
8
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
9
9
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
10
10
  import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
11
+ import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
11
12
  import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
12
13
  import ServiceConnections from '~/components/panels/inspector/ServiceConnections.vue'
13
14
  import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
@@ -496,6 +497,9 @@ const showOriginalDescription = ref(false)
496
497
  <!-- service (frame): test infra + provisioning configuration -->
497
498
  <ServiceTestConfig v-if="isFrame" :key="`test-config-${block.id}`" :block="block" />
498
499
 
500
+ <!-- service (frame): SENSITIVE test credentials (sealed, injected out of band) -->
501
+ <ServiceTestSecrets v-if="isFrame" :key="`test-secrets-${block.id}`" :block="block" />
502
+
499
503
  <!-- service (frame): best-practice fragments for code-aware agents -->
500
504
  <ServiceFragments v-if="isFrame" :key="`fragments-${block.id}`" :block="block" />
501
505
 
@@ -0,0 +1,264 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref, watch } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import SecretInput from '~/components/common/SecretInput.vue'
6
+
7
+ // Per-service (frame) SENSITIVE test credentials: a genuinely secret token a Tester needs
8
+ // to exercise a third-party integration (e.g. a Stripe API key). Unlike the non-sensitive
9
+ // pools, these are SEALED at rest and injected into the Tester container OUT OF BAND — never
10
+ // rendered into a prompt or the telemetry snapshot. Keyed by THIS frame's block id.
11
+ //
12
+ // The backend stores the WHOLE set and values are write-only (never read back), so this is a
13
+ // full-set replace editor: saving persists exactly the rows below and drops anything omitted.
14
+ // The list prefills from the configured keys/descriptions; every value must be (re-)entered,
15
+ // and Save stays disabled until each row has one — so an existing secret can never be blanked
16
+ // by accident. Hidden entirely when the backend store is unconfigured (no ENCRYPTION_KEY).
17
+ const props = defineProps<{ block: Block }>()
18
+
19
+ const store = useTestSecretsStore()
20
+ const toast = useToast()
21
+ const { t } = useI18n()
22
+ const { confirmAction, toastDone } = useConfirmAction()
23
+
24
+ const busy = ref(false)
25
+
26
+ interface DraftRow {
27
+ key: string
28
+ description: string
29
+ value: string
30
+ }
31
+ const draft = reactive<{ rows: DraftRow[] }>({ rows: [] })
32
+
33
+ const configured = computed(() => store.entriesForBlock(props.block.id))
34
+ const available = computed(() => store.available !== false)
35
+
36
+ const blankRow = (): DraftRow => ({ key: '', description: '', value: '' })
37
+
38
+ // Load this frame's configured refs once, then (re)hydrate the editor from them. Runs again
39
+ // after a save/clear (the store refs change) so the just-typed secret values don't linger in
40
+ // the form — the persisted set is re-shown with empty value fields to re-enter.
41
+ onMounted(() => {
42
+ store.ensureLoaded(props.block.id).catch(() => {})
43
+ })
44
+ watch(
45
+ configured,
46
+ (entries) => {
47
+ draft.rows = entries.length
48
+ ? entries.map((e) => ({ key: e.key, description: e.description, value: '' }))
49
+ : [blankRow()]
50
+ },
51
+ { immediate: true },
52
+ )
53
+
54
+ // A valid POSIX env-var name (mirrors the contract's testSecretKeySchema regex + max length).
55
+ // The reserved/toolchain-name rejection lives server-side and surfaces as a save error — we
56
+ // don't duplicate the harness's reserved-name list here.
57
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
58
+ const KEY_MAX = 128
59
+ function keyValid(key: string): boolean {
60
+ const k = key.trim()
61
+ return KEY_RE.test(k) && k.length <= KEY_MAX
62
+ }
63
+
64
+ // Keys that appear more than once (trimmed) — flagged inline and block saving.
65
+ const duplicateKeys = computed(() => {
66
+ const seen = new Map<string, number>()
67
+ for (const r of draft.rows) {
68
+ const k = r.key.trim()
69
+ if (k) seen.set(k, (seen.get(k) ?? 0) + 1)
70
+ }
71
+ return new Set([...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k))
72
+ })
73
+
74
+ function rowComplete(r: DraftRow): boolean {
75
+ return keyValid(r.key) && r.value.trim().length > 0
76
+ }
77
+
78
+ const canSave = computed(
79
+ () =>
80
+ !busy.value &&
81
+ draft.rows.length > 0 &&
82
+ draft.rows.every(rowComplete) &&
83
+ duplicateKeys.value.size === 0,
84
+ )
85
+
86
+ function addRow() {
87
+ draft.rows.push(blankRow())
88
+ }
89
+ function removeRow(index: number) {
90
+ draft.rows.splice(index, 1)
91
+ }
92
+
93
+ function notifyError(title: string, e: unknown) {
94
+ toast.add({
95
+ title,
96
+ description: e instanceof Error ? e.message : String(e),
97
+ icon: 'i-lucide-triangle-alert',
98
+ color: 'error',
99
+ })
100
+ }
101
+
102
+ async function save() {
103
+ busy.value = true
104
+ try {
105
+ await store.save(props.block.id, {
106
+ entries: draft.rows.map((r) => ({
107
+ key: r.key.trim(),
108
+ description: r.description.trim(),
109
+ value: r.value,
110
+ })),
111
+ })
112
+ toast.add({
113
+ title: t('inspector.testSecrets.savedToast'),
114
+ icon: 'i-lucide-check',
115
+ color: 'success',
116
+ })
117
+ } catch (e) {
118
+ notifyError(t('inspector.testSecrets.saveFailed'), e)
119
+ } finally {
120
+ busy.value = false
121
+ }
122
+ }
123
+
124
+ async function clearAll() {
125
+ const noun = t('inspector.testSecrets.configNoun')
126
+ if (!(await confirmAction('clear', noun))) return
127
+ busy.value = true
128
+ try {
129
+ await store.clear(props.block.id)
130
+ toastDone('clear', noun)
131
+ } catch (e) {
132
+ notifyError(t('inspector.testSecrets.clearFailed'), e)
133
+ } finally {
134
+ busy.value = false
135
+ }
136
+ }
137
+ </script>
138
+
139
+ <template>
140
+ <InspectorSection
141
+ v-if="available"
142
+ :title="t('inspector.testSecrets.title')"
143
+ :hint="t('inspector.testSecrets.sectionHint')"
144
+ :count="configured.length"
145
+ warning
146
+ default-open
147
+ data-testid="service-test-secrets"
148
+ >
149
+ <template #actions>
150
+ <UButton
151
+ v-if="configured.length"
152
+ color="error"
153
+ variant="ghost"
154
+ size="xs"
155
+ icon="i-lucide-trash-2"
156
+ :loading="busy"
157
+ data-testid="test-secrets-clear"
158
+ @click="clearAll"
159
+ >
160
+ {{ t('inspector.testSecrets.clear') }}
161
+ </UButton>
162
+ </template>
163
+
164
+ <!-- These are REAL secrets: an unmistakable sensitivity + replace-all warning. -->
165
+ <div
166
+ class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-2.5 py-2 text-[11px] leading-snug text-amber-200"
167
+ >
168
+ <UIcon name="i-lucide-shield-alert" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
169
+ <span>{{ t('inspector.testSecrets.warning') }}</span>
170
+ </div>
171
+
172
+ <p class="text-[11px] leading-snug text-slate-500">
173
+ {{ t('inspector.testSecrets.replaceNote') }}
174
+ </p>
175
+
176
+ <div class="space-y-3">
177
+ <div
178
+ v-for="(row, index) in draft.rows"
179
+ :key="index"
180
+ class="space-y-2 rounded-md border border-slate-800 p-2.5"
181
+ :data-testid="`test-secret-row-${index}`"
182
+ >
183
+ <div class="flex items-start gap-2">
184
+ <UFormField
185
+ :label="t('inspector.testSecrets.key')"
186
+ :error="
187
+ row.key.trim() && !keyValid(row.key)
188
+ ? t('inspector.testSecrets.keyInvalid')
189
+ : undefined
190
+ "
191
+ class="flex-1"
192
+ >
193
+ <UInput
194
+ v-model="row.key"
195
+ placeholder="STRIPE_API_KEY"
196
+ size="sm"
197
+ class="w-full font-mono"
198
+ :data-testid="`test-secret-key-${index}`"
199
+ />
200
+ </UFormField>
201
+ <UButton
202
+ color="error"
203
+ variant="ghost"
204
+ size="xs"
205
+ icon="i-lucide-x"
206
+ class="mt-5 shrink-0"
207
+ :aria-label="t('inspector.testSecrets.removeRow')"
208
+ :data-testid="`test-secret-remove-${index}`"
209
+ @click="removeRow(index)"
210
+ />
211
+ </div>
212
+
213
+ <UFormField :label="t('inspector.testSecrets.description')">
214
+ <UInput
215
+ v-model="row.description"
216
+ :placeholder="t('inspector.testSecrets.descriptionPlaceholder')"
217
+ size="sm"
218
+ class="w-full"
219
+ :data-testid="`test-secret-description-${index}`"
220
+ />
221
+ </UFormField>
222
+
223
+ <UFormField :label="t('inspector.testSecrets.value')">
224
+ <SecretInput
225
+ v-model="row.value"
226
+ :placeholder="t('inspector.testSecrets.valuePlaceholder')"
227
+ size="sm"
228
+ class="w-full"
229
+ :data-testid="`test-secret-value-${index}`"
230
+ />
231
+ </UFormField>
232
+ </div>
233
+
234
+ <p v-if="duplicateKeys.size" class="text-[11px] text-error-400">
235
+ {{ t('inspector.testSecrets.duplicateKey') }}
236
+ </p>
237
+
238
+ <div class="flex items-center justify-between gap-2">
239
+ <UButton
240
+ color="neutral"
241
+ variant="soft"
242
+ size="xs"
243
+ icon="i-lucide-plus"
244
+ data-testid="test-secret-add"
245
+ @click="addRow"
246
+ >
247
+ {{ t('inspector.testSecrets.addRow') }}
248
+ </UButton>
249
+ <UButton
250
+ color="primary"
251
+ variant="soft"
252
+ size="xs"
253
+ icon="i-lucide-save"
254
+ :loading="busy"
255
+ :disabled="!canSave"
256
+ data-testid="test-secrets-save"
257
+ @click="save"
258
+ >
259
+ {{ t('inspector.testSecrets.save') }}
260
+ </UButton>
261
+ </div>
262
+ </div>
263
+ </InspectorSection>
264
+ </template>
@@ -0,0 +1,36 @@
1
+ import {
2
+ deleteServiceTestSecretsContract,
3
+ getServiceTestSecretsContract,
4
+ setServiceTestSecretsContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { UpsertServiceTestSecretsInput } from '~/types/testSecrets'
7
+ import type { ApiContext } from './context'
8
+
9
+ /**
10
+ * Sensitive per-service test secrets (SEALED, write-only). The GET view returns only the
11
+ * configured keys + descriptions; the PUT replaces the whole set (values write-only) and
12
+ * an empty set clears it. Keyed by the service-frame block. See TestSecretsController.
13
+ */
14
+ export function testSecretsApi({ send, ws }: ApiContext) {
15
+ return {
16
+ getServiceTestSecrets: (workspaceId: string, blockId: string) =>
17
+ send(getServiceTestSecretsContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
18
+
19
+ setServiceTestSecrets: (
20
+ workspaceId: string,
21
+ blockId: string,
22
+ body: UpsertServiceTestSecretsInput,
23
+ ) =>
24
+ send(setServiceTestSecretsContract, {
25
+ pathPrefix: ws(workspaceId),
26
+ pathParams: { blockId },
27
+ body,
28
+ }),
29
+
30
+ deleteServiceTestSecrets: (workspaceId: string, blockId: string) =>
31
+ send(deleteServiceTestSecretsContract, {
32
+ pathPrefix: ws(workspaceId),
33
+ pathParams: { blockId },
34
+ }),
35
+ }
36
+ }
@@ -35,6 +35,7 @@ import { reviewsApi } from './api/reviews'
35
35
  import { slackApi } from './api/slack'
36
36
  import { specApi } from './api/spec'
37
37
  import { tasksApi } from './api/tasks'
38
+ import { testSecretsApi } from './api/testSecrets'
38
39
  import { userSecretsApi } from './api/userSecrets'
39
40
  import { userSettingsApi } from './api/userSettings'
40
41
  import { workspacesApi } from './api/workspaces'
@@ -123,6 +124,7 @@ export function useApi() {
123
124
  ...docInterviewApi(ctx),
124
125
  ...provisioningLogsApi(ctx),
125
126
  ...releaseHealthApi(ctx),
127
+ ...testSecretsApi(ctx),
126
128
  ...packageRegistriesApi(ctx),
127
129
  ...previewApi(ctx),
128
130
  ...environmentsApi(ctx),
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { useUnsavedGuard } from '~/composables/useUnsavedGuard'
4
+
5
+ // Control the shared confirm dialog: `confirm()` resolves to the user's choice (discard
6
+ // vs keep). `useI18n` is already stubbed to a passthrough in test/setup.ts.
7
+ function stubConfirm(result: boolean) {
8
+ const confirm = vi.fn().mockResolvedValue(result)
9
+ vi.stubGlobal('useConfirm', () => ({ confirm }))
10
+ return confirm
11
+ }
12
+
13
+ describe('useUnsavedGuard', () => {
14
+ it('closes immediately without prompting when the form is unchanged', async () => {
15
+ const confirm = stubConfirm(true)
16
+ const close = vi.fn()
17
+ const value = ref('hello')
18
+ const { requestClose } = useUnsavedGuard({
19
+ open: ref(true),
20
+ close,
21
+ snapshot: () => ({ value: value.value }),
22
+ })
23
+
24
+ await requestClose()
25
+
26
+ expect(confirm).not.toHaveBeenCalled()
27
+ expect(close).toHaveBeenCalledOnce()
28
+ })
29
+
30
+ it('prompts and closes when dirty and the user confirms discard', async () => {
31
+ const confirm = stubConfirm(true)
32
+ const close = vi.fn()
33
+ const value = ref('hello')
34
+ const { requestClose, isDirty } = useUnsavedGuard({
35
+ open: ref(true),
36
+ close,
37
+ snapshot: () => ({ value: value.value }),
38
+ })
39
+
40
+ value.value = 'changed'
41
+ expect(isDirty()).toBe(true)
42
+ await requestClose()
43
+
44
+ expect(confirm).toHaveBeenCalledOnce()
45
+ expect(close).toHaveBeenCalledOnce()
46
+ })
47
+
48
+ it('prompts and keeps the modal open when the user cancels discard', async () => {
49
+ const confirm = stubConfirm(false)
50
+ const close = vi.fn()
51
+ const value = ref('hello')
52
+ const { requestClose } = useUnsavedGuard({
53
+ open: ref(true),
54
+ close,
55
+ snapshot: () => ({ value: value.value }),
56
+ })
57
+
58
+ value.value = 'changed'
59
+ await requestClose()
60
+
61
+ expect(confirm).toHaveBeenCalledOnce()
62
+ expect(close).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('never prompts or closes while a submit is in flight', async () => {
66
+ const confirm = stubConfirm(true)
67
+ const close = vi.fn()
68
+ const value = ref('hello')
69
+ const { requestClose } = useUnsavedGuard({
70
+ open: ref(true),
71
+ close,
72
+ saving: () => true,
73
+ snapshot: () => ({ value: value.value }),
74
+ })
75
+
76
+ value.value = 'changed'
77
+ await requestClose()
78
+
79
+ expect(confirm).not.toHaveBeenCalled()
80
+ expect(close).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it('re-baselines the seeded form each time the modal opens', async () => {
84
+ stubConfirm(true)
85
+ const open = ref(false)
86
+ const value = ref('')
87
+ const { isDirty } = useUnsavedGuard({
88
+ open,
89
+ close: vi.fn(),
90
+ snapshot: () => ({ value: value.value }),
91
+ })
92
+
93
+ // A reset watcher seeds a prefill; the guard re-snapshots on open, so the seeded value
94
+ // is the clean baseline rather than a spurious edit.
95
+ value.value = 'seeded prefill'
96
+ open.value = true
97
+ await nextTick()
98
+ expect(isDirty()).toBe(false)
99
+
100
+ // A genuine edit after the modal is open is dirty.
101
+ value.value = 'user typed more'
102
+ expect(isDirty()).toBe(true)
103
+ })
104
+ })