@cat-factory/app 0.236.0 → 0.236.1

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 CHANGED
@@ -79,6 +79,30 @@ earlier than before takes the entire SPA down at boot, and the unit suite cannot
79
79
  (nothing there installs the plugin). Every e2e spec does, because every one of them boots
80
80
  the app.
81
81
 
82
+ ### The persisted board pin is UNVALIDATED until `init()` resolves it
83
+
84
+ `workspace.workspaceId` is restored from persisted state SYNCHRONOUSLY, before any request fires,
85
+ so every `immediate: true` watcher on it (`pages/index.vue`) runs against an id nothing has
86
+ checked. The pin can name a board that was deleted, or one whose access was revoked while the
87
+ browser held it, and the RBAC gate answers both with a 404 (it hides a denial as a not-found, so
88
+ existence never leaks). `init()` then validates the pin against `GET /workspaces` and re-points it
89
+ at a board the user can actually reach.
90
+
91
+ Firing the per-board reads on the pin anyway is deliberate: it overlaps them with the workspace
92
+ list instead of queueing them behind it, which is why `init()` fetches the pinned SNAPSHOT
93
+ speculatively too. **What travels with that is the miss.** Each of those boot reads states its own
94
+ tolerance at its own seam (`init`'s `.catch(() => null)`, `github.ensureProbed`'s internal catch,
95
+ `models.prefetchForBoard`), because a 404 there is an expected outcome and not a fault: the
96
+ watcher fires again for the board init resolved, which is the read that counts. A bare
97
+ `void store.load(workspace.workspaceId)` in that chain is an uncaught rejection in a real user's
98
+ browser, and the e2e suite's `pageErrors` fixture fails the spec that boots a session whose access
99
+ was just revoked.
100
+
101
+ Tolerating the miss is not the same as pretending it succeeded: a dropped load leaves its store
102
+ UNLOADED (`models.loaded` stays false, so `useAiReadiness().ready` is false), which reads as
103
+ unresolved rather than as a board with nothing configured, and leaves the next caller free to
104
+ retry. Pin new boot reads with a store-level unit test (`stores/models.spec.ts`).
105
+
82
106
  ### A backend-DECLARED form renders through `DescriptorFields.vue`
83
107
 
84
108
  When the backend declares the fields and the SPA only collects them, render them with the shared
@@ -205,7 +205,10 @@ const noSignInMethod = computed(
205
205
  </script>
206
206
 
207
207
  <template>
208
- <div class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100">
208
+ <div
209
+ class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100"
210
+ data-testid="login-screen"
211
+ >
209
212
  <div
210
213
  class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
211
214
  >
@@ -392,6 +395,7 @@ const noSignInMethod = computed(
392
395
  icon="i-lucide-at-sign"
393
396
  size="lg"
394
397
  class="w-full"
398
+ data-testid="login-email"
395
399
  />
396
400
  <SecretInput
397
401
  v-model="password"
@@ -400,9 +404,17 @@ const noSignInMethod = computed(
400
404
  icon="i-lucide-lock"
401
405
  size="lg"
402
406
  class="w-full"
407
+ data-testid="login-password"
403
408
  />
404
- <p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
405
- <UButton block size="lg" color="primary" type="submit" :loading="busy">
409
+ <p v-if="error" class="text-sm text-rose-400" data-testid="login-error">{{ error }}</p>
410
+ <UButton
411
+ block
412
+ size="lg"
413
+ color="primary"
414
+ type="submit"
415
+ :loading="busy"
416
+ data-testid="login-submit"
417
+ >
406
418
  {{ mode === 'signup' ? t('auth.login.createAccount') : t('auth.login.signIn') }}
407
419
  </UButton>
408
420
  <p class="text-center text-xs text-slate-400">
@@ -35,6 +35,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
35
35
  <UDropdownMenu v-if="auth.user" :items="items" :content="{ side: 'top', align: 'start' }">
36
36
  <button
37
37
  type="button"
38
+ data-testid="user-menu"
38
39
  :title="collapsed ? auth.user.name || auth.user.login : undefined"
39
40
  class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
40
41
  :class="collapsed ? 'justify-center' : ''"
@@ -187,6 +187,7 @@ function memberLabel(userId: string, name?: string | null, email?: string | null
187
187
  :key="m.userId"
188
188
  class="flex items-center justify-between gap-2 rounded-md bg-slate-800/40 px-2 py-1"
189
189
  data-testid="workspace-member-row"
190
+ :data-user-id="m.userId"
190
191
  >
191
192
  <span class="truncate">{{ memberLabel(m.userId, m.name, m.email) }}</span>
192
193
  <span class="flex shrink-0 items-center gap-2">
@@ -333,6 +333,7 @@ const technicalLabel = computed(() => {
333
333
  color="neutral"
334
334
  icon="i-lucide-git-merge"
335
335
  trailing-icon="i-lucide-chevron-down"
336
+ data-testid="risk-policy-picker-trigger"
336
337
  />
337
338
  </template>
338
339
  </RiskPolicyPicker>
@@ -113,6 +113,9 @@ function choose(id: string) {
113
113
 
114
114
  <template>
115
115
  <UPopover v-model:open="open" :content="{ align: 'start' }">
116
+ <!-- A consumer that supplies its own `#trigger` must carry `risk-policy-picker-trigger` on it:
117
+ the popover trigger is `as-child`, so the slotted element REPLACES the default button below
118
+ and takes its test hook with it (the inspector's icon button is the one such consumer). -->
116
119
  <slot name="trigger" :label="triggerLabel">
117
120
  <UButton
118
121
  color="neutral"
@@ -282,7 +282,7 @@ async function create() {
282
282
  </script>
283
283
 
284
284
  <template>
285
- <div class="space-y-4">
285
+ <div class="space-y-4" data-testid="risk-policy-panel">
286
286
  <i18n-t
287
287
  keypath="settings.riskPolicy.intro"
288
288
  tag="p"
@@ -298,6 +298,8 @@ async function create() {
298
298
  v-for="p in store.presets"
299
299
  :key="p.id"
300
300
  class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
301
+ data-testid="risk-policy-row"
302
+ :data-policy-id="p.id"
301
303
  >
302
304
  <div class="mb-3 flex items-center gap-2">
303
305
  <UInput
@@ -478,6 +480,7 @@ async function create() {
478
480
  v-model="draft.name"
479
481
  size="sm"
480
482
  :placeholder="t('settings.riskPolicy.create.namePlaceholder')"
483
+ data-testid="risk-policy-create-name"
481
484
  />
482
485
  </label>
483
486
  <label class="block w-20">
@@ -490,19 +493,34 @@ async function create() {
490
493
  :min="0"
491
494
  :max="100"
492
495
  size="sm"
496
+ data-testid="risk-policy-create-complexity"
493
497
  />
494
498
  </label>
495
499
  <label class="block w-20">
496
500
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
497
501
  {{ t('settings.riskPolicy.create.risk') }}
498
502
  </span>
499
- <UInput v-model.number="draft.maxRisk" type="number" :min="0" :max="100" size="sm" />
503
+ <UInput
504
+ v-model.number="draft.maxRisk"
505
+ type="number"
506
+ :min="0"
507
+ :max="100"
508
+ size="sm"
509
+ data-testid="risk-policy-create-risk"
510
+ />
500
511
  </label>
501
512
  <label class="block w-20">
502
513
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
503
514
  {{ t('settings.riskPolicy.create.impact') }}
504
515
  </span>
505
- <UInput v-model.number="draft.maxImpact" type="number" :min="0" :max="100" size="sm" />
516
+ <UInput
517
+ v-model.number="draft.maxImpact"
518
+ type="number"
519
+ :min="0"
520
+ :max="100"
521
+ size="sm"
522
+ data-testid="risk-policy-create-impact"
523
+ />
506
524
  </label>
507
525
  <label class="block w-20">
508
526
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
@@ -549,6 +567,7 @@ async function create() {
549
567
  icon="i-lucide-plus"
550
568
  :loading="creating"
551
569
  :disabled="!draft.name.trim()"
570
+ data-testid="risk-policy-create-submit"
552
571
  @click="create"
553
572
  >
554
573
  {{ t('settings.riskPolicy.add') }}
@@ -312,6 +312,13 @@ async function save() {
312
312
  </template>
313
313
  <template #body>
314
314
  <UTabs v-model="activeTab" :items="tabs" variant="link" :ui="tabsUi">
315
+ <!-- The tab LABEL, overridden only to carry a per-tab test hook: `UTabs` renders its own
316
+ triggers and forwards nothing from an item, so this slot is the one place a stable
317
+ selector can name which tab a click means (the labels themselves are translated). -->
318
+ <template #default="{ item }">
319
+ <span :data-testid="`workspace-settings-tab-${item.value}`">{{ item.label }}</span>
320
+ </template>
321
+
315
322
  <!-- Workspace -->
316
323
  <template #workspace>
317
324
  <div class="space-y-6">
@@ -13,9 +13,10 @@ import type { ModelPreset } from '~/types/model-presets'
13
13
  * still points at one or more that aren't usable (⇒ the preset-mismatch prompt). Gated on
14
14
  * `hasUsableModel` so the no-AI prompt owns the "nothing works" case on its own.
15
15
  *
16
- * Read-only over the existing stores; the catalog is loaded elsewhere (on workspace-ready
17
- * and after credential edits), so `ready` simply reports whether that load has landed for
18
- * the active workspace.
16
+ * Read-only over the existing stores; the catalog is loaded elsewhere (on the active board
17
+ * changing and after credential edits), so `ready` simply reports whether that load has landed
18
+ * for the active workspace. A load that FAILED leaves it false, which is what keeps the no-AI
19
+ * prompt off a board whose catalog never arrived (see `models.prefetchForBoard`).
19
20
  */
20
21
  export function useAiReadiness() {
21
22
  const models = useModelsStore()
@@ -191,10 +191,16 @@ const autoOpenedPreset = ref(false)
191
191
  // availability reflects that workspace's keys/subscriptions). This populates the AI-readiness
192
192
  // signals regardless of which lazy picker happens to mount, so the onboarding prompts below
193
193
  // can fire. Credential edits re-fetch via `models.refresh()` in the provider panels.
194
+ //
195
+ // Through `prefetchForBoard` because the FIRST run reads the persisted pin, which `init()` has
196
+ // not validated yet: a board that was deleted, or whose access was revoked while the browser
197
+ // held the pin, 404s here exactly as it does for init's own speculative snapshot fetch. That
198
+ // board is not this watcher's last word (init re-points the pin and it fires again), so the
199
+ // miss is dropped rather than left to surface as an uncaught rejection in the page.
194
200
  watch(
195
201
  () => workspace.workspaceId,
196
202
  (id, prev) => {
197
- if (id) void models.ensureLoaded(id)
203
+ if (id) void models.prefetchForBoard(id)
198
204
  // Switching workspaces resets the per-session AI-onboarding state: dismissals and the
199
205
  // auto-open guards are scoped to one workspace, so a prompt dismissed in workspace A must
200
206
  // not suppress the (independent) prompt for workspace B that also lacks a usable source.
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { ModelOption } from '~/types/domain'
3
+ import { useModelsStore } from '~/stores/models'
4
+
5
+ /** Minimal catalog entry: only the fields the store's own reads touch. */
6
+ function model(over: Partial<ModelOption> = {}): ModelOption {
7
+ return {
8
+ id: 'qwen3',
9
+ label: 'Qwen3',
10
+ description: '',
11
+ flavor: 'cloudflare',
12
+ providerLabel: 'Cloudflare',
13
+ provider: 'cloudflare',
14
+ model: 'qwen3',
15
+ available: true,
16
+ ...over,
17
+ } as ModelOption
18
+ }
19
+
20
+ // The boot-time catalog load runs against the PERSISTED PIN, before `workspace.init()` has
21
+ // validated it against the board list. A pin can name a board that was deleted, or one whose
22
+ // access was revoked while the browser held it, and the RBAC gate answers both with a 404, so
23
+ // the load has to tolerate a miss. Left bare it was an uncaught rejection in the page (the
24
+ // `Workspace not found` a removed member's browser threw on their next visit).
25
+ describe('models store: the speculative load of an unvalidated pin', () => {
26
+ it('drops a pin that 404s and leaves the catalog retryable for the board init resolves', async () => {
27
+ const get = vi
28
+ .fn<(workspaceId: string) => Promise<ModelOption[]>>()
29
+ .mockRejectedValueOnce(Object.assign(new Error('Workspace not found'), { statusCode: 404 }))
30
+ .mockResolvedValueOnce([model()])
31
+ vi.stubGlobal('useApi', () => ({ getWorkspaceModels: get }))
32
+
33
+ const store = useModelsStore()
34
+ // The revoked pin. Resolves rather than rejects: nothing in the page catches it.
35
+ await expect(store.prefetchForBoard('ws_revoked')).resolves.toBeUndefined()
36
+
37
+ // Nothing was latched, so this reads as UNRESOLVED rather than as a board with no models
38
+ // (`useAiReadiness().ready` is `loaded && loadedWorkspaceId === workspaceId`, which is what
39
+ // keeps the no-AI onboarding prompt from firing off a catalog that never landed).
40
+ expect(store.loaded).toBe(false)
41
+ expect(store.loadedWorkspaceId).toBeNull()
42
+ expect(store.models).toEqual([])
43
+
44
+ // ...and the board `init()` resolves instead still loads, on the same store.
45
+ await store.ensureLoaded('ws_reachable')
46
+ expect(store.loaded).toBe(true)
47
+ expect(store.loadedWorkspaceId).toBe('ws_reachable')
48
+ expect(store.models).toHaveLength(1)
49
+ })
50
+
51
+ it('a pin that IS reachable loads the catalog once, and the later caller reuses it', async () => {
52
+ const get = vi.fn(() => Promise.resolve([model()]))
53
+ vi.stubGlobal('useApi', () => ({ getWorkspaceModels: get }))
54
+
55
+ const store = useModelsStore()
56
+ await store.prefetchForBoard('ws1')
57
+ // What the cold open pays for: `init()` hydrating the same board finds the catalog already
58
+ // there, so the prefetch is one request rather than a duplicate of the one that follows it.
59
+ await store.ensureLoaded('ws1')
60
+
61
+ expect(get).toHaveBeenCalledTimes(1)
62
+ expect(store.hasUsableModel).toBe(true)
63
+ })
64
+ })
@@ -122,6 +122,26 @@ export const useModelsStore = defineStore('models', () => {
122
122
  loaded.value = true
123
123
  }
124
124
 
125
+ /**
126
+ * Load the catalog for a board the app has NOT yet validated: the persisted pin at boot,
127
+ * fetched in parallel with the workspace list rather than after it.
128
+ *
129
+ * A pin can name a board that was deleted, or one whose access has since been revoked, and the
130
+ * gate answers both with a 404 (`workspace.init()` guards the same speculative read of the same
131
+ * id with `.catch(() => null)`). So a failure here is an expected outcome rather than a fault,
132
+ * and dropping it is safe in both directions: `loaded` stays false, so this leaves the catalog
133
+ * RETRYABLE for the next `ensureLoaded` caller, and `useAiReadiness().ready` stays false, so a
134
+ * catalog that never landed reads as unresolved instead of as a board with no AI configured.
135
+ */
136
+ async function prefetchForBoard(workspaceId: string): Promise<void> {
137
+ try {
138
+ await ensureLoaded(workspaceId)
139
+ } catch {
140
+ // Deliberate: see above. `init()` re-points the board it resolved, which loads the catalog
141
+ // that counts; anything else retries on the next caller.
142
+ }
143
+ }
144
+
125
145
  /** Force a re-fetch of the per-workspace catalog (e.g. after adding an API key). */
126
146
  async function refresh(workspaceId: string) {
127
147
  models.value = await api.getWorkspaceModels(workspaceId)
@@ -180,6 +200,7 @@ export const useModelsStore = defineStore('models', () => {
180
200
  loaded,
181
201
  loadedWorkspaceId,
182
202
  ensureLoaded,
203
+ prefetchForBoard,
183
204
  refresh,
184
205
  byId,
185
206
  getModel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.236.0",
3
+ "version": "0.236.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",