@cat-factory/app 0.235.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.
Files changed (34) hide show
  1. package/README.md +24 -0
  2. package/app/components/auth/LoginScreen.vue +15 -3
  3. package/app/components/auth/UserMenu.vue +1 -0
  4. package/app/components/board/AddTaskModal.vue +31 -12
  5. package/app/components/board/CreateInitiativeModal.vue +33 -6
  6. package/app/components/context/ContextAttachmentFields.vue +63 -42
  7. package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
  8. package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
  9. package/app/components/documents/ContextDocumentPicker.vue +269 -34
  10. package/app/components/layout/WorkspaceMembersSettings.vue +1 -0
  11. package/app/components/panels/inspector/TaskRunSettings.vue +1 -0
  12. package/app/components/riskPolicy/RiskPolicyPicker.vue +3 -0
  13. package/app/components/settings/RiskPolicyPanel.vue +22 -3
  14. package/app/components/settings/WorkspaceSettingsPanel.vue +7 -0
  15. package/app/composables/api/documents.ts +10 -0
  16. package/app/composables/useAiReadiness.ts +4 -3
  17. package/app/composables/useContextLinking.spec.ts +95 -0
  18. package/app/composables/useContextLinking.ts +96 -15
  19. package/app/pages/index.vue +7 -1
  20. package/app/stores/documents.ts +12 -0
  21. package/app/stores/models.spec.ts +64 -0
  22. package/app/stores/models.ts +21 -0
  23. package/app/types/documents.ts +2 -0
  24. package/i18n/locales/de.json +15 -4
  25. package/i18n/locales/en.json +19 -2
  26. package/i18n/locales/es.json +15 -4
  27. package/i18n/locales/fr.json +15 -4
  28. package/i18n/locales/he.json +15 -4
  29. package/i18n/locales/it.json +15 -4
  30. package/i18n/locales/ja.json +15 -4
  31. package/i18n/locales/pl.json +15 -4
  32. package/i18n/locales/tr.json +15 -4
  33. package/i18n/locales/uk.json +15 -4
  34. package/package.json +2 -2
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' : ''"
@@ -57,7 +57,7 @@ const fragments = useFragmentsStore()
57
57
  const toast = useToast()
58
58
  const { t } = useI18n()
59
59
 
60
- const { linkPending, presentLinkFailures } = useContextLinking()
60
+ const { resolvePending, linkPending, presentLinkFailures } = useContextLinking()
61
61
 
62
62
  const open = computed(() => ui.addTaskContainerId !== null)
63
63
 
@@ -453,8 +453,13 @@ const resolvingIssueBodies = ref(false)
453
453
  // A staged issue picked from search results carries no body yet (`needsImport`, and the
454
454
  // search result has no description). Resolve it once the form opens — from the local cache
455
455
  // when already imported, else by importing it (idempotent; we'd import on add anyway) — so
456
- // its description can be shown read-only and folded into the task. Best-effort: a failure
457
- // just leaves that issue without a preview, still linked on add.
456
+ // its description can be shown read-only and folded into the task.
457
+ //
458
+ // Non-fatal (the form still opens), but NOT silent: an issue this cannot read is the very issue
459
+ // that will block the submit, since the fetch moved ahead of the create. Recording the cause on the
460
+ // item is what turns that into a warning the author sees NOW, on a chip they can remove, instead of
461
+ // a create refused seconds later for a reason nothing on the form ever mentioned. A tracker
462
+ // reference has no `parseRef`-style pre-flight to ask, so this attempt IS its pre-flight.
458
463
  async function resolvePendingIssueBodies() {
459
464
  const unresolved = pendingContext.value.filter(
460
465
  (c) => c.kind === 'task' && !(c.description ?? '').trim(),
@@ -463,6 +468,7 @@ async function resolvePendingIssueBodies() {
463
468
  resolvingIssueBodies.value = true
464
469
  try {
465
470
  const resolved: Record<string, string> = {}
471
+ const failed: Record<string, string> = {}
466
472
  for (const item of unresolved) {
467
473
  const source = item.source as TaskSourceKind
468
474
  const cached = tasks.tasks.find(
@@ -476,15 +482,16 @@ async function resolvePendingIssueBodies() {
476
482
  try {
477
483
  const imported = await tasks.importTask(source, item.externalId)
478
484
  if ((imported.description ?? '').trim()) resolved[contextKey(item)] = imported.description
479
- } catch {
480
- // Unreadable/forbidden issue skip the preview; it still links on add.
485
+ } catch (e) {
486
+ failed[contextKey(item)] = e instanceof Error ? e.message : String(e)
481
487
  }
482
488
  }
483
- if (Object.keys(resolved).length) {
484
- // The issue is now imported, so it links directly on add (needsImport → false).
489
+ if (Object.keys(resolved).length || Object.keys(failed).length) {
485
490
  pendingContext.value = pendingContext.value.map((c) => {
486
- const body = resolved[contextKey(c)]
487
- return body ? { ...c, description: body, needsImport: false } : c
491
+ const key = contextKey(c)
492
+ // The issue is now imported, so it links directly on add (needsImport → false).
493
+ if (resolved[key]) return { ...c, description: resolved[key], needsImport: false }
494
+ return failed[key] ? { ...c, unreadable: failed[key] } : c
488
495
  })
489
496
  }
490
497
  } finally {
@@ -642,6 +649,18 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
642
649
  if (!containerId) return
643
650
  saving.value = true
644
651
  try {
652
+ // Attachments are fetched BEFORE the task is written. A page that moved, a token without
653
+ // access or a source that is down is a correction the user can still make with the form in
654
+ // front of them; the same failure after the create leaves a task carrying context it never
655
+ // got, reported by a toast over a closed dialog.
656
+ const { resolved, failures } = await resolvePending(pendingContext.value)
657
+ pendingContext.value = resolved
658
+ if (failures.length) {
659
+ presentLinkFailures(failures, undefined, {
660
+ title: (count) => t('board.addTask.contextFailed', { count }, count),
661
+ })
662
+ return
663
+ }
645
664
  const typeFields = buildTypeFields()
646
665
  // The saved description includes each linked issue's body (shown read-only above)
647
666
  // followed by the user's own notes, so the original issue description is part of the
@@ -672,9 +691,9 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
672
691
  ...(acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
673
692
  })
674
693
  if (block) {
675
- // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
676
- // permission/visibility error, a not-found doc, ) instead of a bare count, plus a
677
- // one-click "Copy details" for a bug report.
694
+ // Everything reachable was fetched above, so what can still fail here is the LINK itself
695
+ // (a doc another task already holds). Surfaced with its specific cause plus a one-click
696
+ // "Copy details" for a bug report, and after the create because the task is already sound.
678
697
  presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
679
698
  }
680
699
  ui.closeReviewFriction()
@@ -32,7 +32,7 @@ const board = useBoardStore()
32
32
  const initiatives = useInitiativesStore()
33
33
  const toast = useToast()
34
34
  const { t } = useI18n()
35
- const { linkPending, presentLinkFailures } = useContextLinking()
35
+ const { resolvePending, linkPending, presentLinkFailures } = useContextLinking()
36
36
 
37
37
  const open = computed({
38
38
  get: () => ui.createInitiativeFrameId !== null,
@@ -56,6 +56,14 @@ const description = ref('')
56
56
  const inputs = ref<InitiativePresetInputs>({})
57
57
  // Context the user chose to attach, committed once the initiative block exists (see create()).
58
58
  const pendingContext = ref<PendingContext[]>([])
59
+ /**
60
+ * Whether THIS form is mid-submit. `initiatives.creating` cannot answer that any more: it is set
61
+ * inside `initiatives.create`, which the attachment fetch in `create()` now runs several network
62
+ * round trips ahead of. Through those seconds the button looked idle and enabled, and a second click
63
+ * re-entered with the ORIGINAL `pendingContext` (reassigned only after every import settles),
64
+ * re-imported everything and created a SECOND initiative. The add-task form's `saving` is the model.
65
+ */
66
+ const submitting = ref(false)
59
67
 
60
68
  // Monotonic token so a slow probe response from a since-changed preset/frame is discarded.
61
69
  let probeSeq = 0
@@ -108,6 +116,7 @@ watch(open, (o) => {
108
116
  if (!o) return
109
117
  title.value = ''
110
118
  description.value = ''
119
+ submitting.value = false
111
120
  pendingContext.value = []
112
121
  selectedPresetId.value = GENERIC_PRESET_ID
113
122
  applyPreset()
@@ -119,14 +128,30 @@ const presetProblems = computed(() =>
119
128
  selectedPreset.value ? validateInitiativePresetInputs(selectedPreset.value, inputs.value) : [],
120
129
  )
121
130
  const canSubmit = computed(
122
- () => title.value.trim().length > 0 && presetProblems.value.length === 0 && !initiatives.creating,
131
+ () =>
132
+ title.value.trim().length > 0 &&
133
+ presetProblems.value.length === 0 &&
134
+ !submitting.value &&
135
+ !initiatives.creating,
123
136
  )
124
137
 
125
138
  async function create() {
126
139
  const frameId = ui.createInitiativeFrameId
127
140
  if (!frameId || !canSubmit.value) return
128
141
  const descriptor = selectedPreset.value
142
+ submitting.value = true
129
143
  try {
144
+ // Attachments are fetched BEFORE the initiative is written, for the reason the add-task form
145
+ // does it: an unreachable page is a correction the user can still make here, where the same
146
+ // failure after the create leaves an initiative carrying context it never got.
147
+ const { resolved, failures } = await resolvePending(pendingContext.value)
148
+ pendingContext.value = resolved
149
+ if (failures.length) {
150
+ presentLinkFailures(failures, undefined, {
151
+ title: (count) => t('initiative.create.contextFailed', { count }, count),
152
+ })
153
+ return
154
+ }
130
155
  const { block } = await initiatives.create(frameId, {
131
156
  title: title.value.trim(),
132
157
  description: description.value.trim() || undefined,
@@ -135,9 +160,9 @@ async function create() {
135
160
  ? sanitizeInitiativePresetInputs(descriptor, inputs.value)
136
161
  : undefined,
137
162
  })
138
- // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
139
- // permission/visibility error, a not-found doc, …) rather than a bare count. The initiative
140
- // itself is already created, so a failed attachment never costs the user the form.
163
+ // Everything reachable was fetched above, so what can still fail here is the LINK itself (a
164
+ // doc another task already holds), surfaced with its specific cause rather than a bare count.
165
+ // The initiative is already created, so a failed link never costs the user the form.
141
166
  presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id, {
142
167
  title: (count) => t('initiative.create.linkFailed', { count }, count),
143
168
  })
@@ -151,6 +176,8 @@ async function create() {
151
176
  icon: 'i-lucide-triangle-alert',
152
177
  color: 'error',
153
178
  })
179
+ } finally {
180
+ submitting.value = false
154
181
  }
155
182
  }
156
183
  </script>
@@ -266,7 +293,7 @@ async function create() {
266
293
  <UButton
267
294
  data-testid="create-initiative-submit"
268
295
  color="primary"
269
- :loading="initiatives.creating"
296
+ :loading="submitting || initiatives.creating"
270
297
  :disabled="!canSubmit"
271
298
  @click="create"
272
299
  >
@@ -167,29 +167,38 @@ function removePending(item: PendingContext) {
167
167
  <div
168
168
  v-for="item in pendingDocs"
169
169
  :key="contextKey(item)"
170
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
170
+ class="rounded-md border border-slate-800 bg-slate-900/60"
171
171
  >
172
- <UIcon
173
- :name="item.icon ?? 'i-lucide-file-text'"
174
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
175
- />
176
- <span class="truncate">{{ item.title }}</span>
177
- <UBadge
178
- v-if="item.needsImport"
179
- color="neutral"
180
- variant="soft"
181
- size="xs"
182
- class="ms-1 shrink-0"
172
+ <div class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-slate-300">
173
+ <UIcon
174
+ :name="item.icon ?? 'i-lucide-file-text'"
175
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
176
+ />
177
+ <span class="truncate">{{ item.title }}</span>
178
+ <UBadge
179
+ v-if="item.needsImport"
180
+ color="neutral"
181
+ variant="soft"
182
+ size="xs"
183
+ class="ms-1 shrink-0"
184
+ >
185
+ {{ t('contextAttachments.importsOnAdd') }}
186
+ </UBadge>
187
+ <button
188
+ type="button"
189
+ class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
190
+ @click="removePending(item)"
191
+ >
192
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
193
+ </button>
194
+ </div>
195
+ <p
196
+ v-if="item.unreadable"
197
+ class="px-2 pb-1.5 text-[11px] text-amber-400"
198
+ data-testid="context-item-unreadable"
183
199
  >
184
- {{ t('contextAttachments.importsOnAdd') }}
185
- </UBadge>
186
- <button
187
- type="button"
188
- class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
189
- @click="removePending(item)"
190
- >
191
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
192
- </button>
200
+ {{ t('contextAttachments.unreadable', { error: item.unreadable }) }}
201
+ </p>
193
202
  </div>
194
203
  </div>
195
204
  <p v-else class="text-[11px] text-slate-500">
@@ -266,29 +275,41 @@ function removePending(item: PendingContext) {
266
275
  <div
267
276
  v-for="item in pendingIssues"
268
277
  :key="contextKey(item)"
269
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
278
+ class="rounded-md border border-slate-800 bg-slate-900/60"
270
279
  >
271
- <UIcon
272
- :name="item.icon ?? 'i-lucide-square-check'"
273
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
274
- />
275
- <span class="truncate">{{ item.title }}</span>
276
- <UBadge
277
- v-if="item.needsImport"
278
- color="neutral"
279
- variant="soft"
280
- size="xs"
281
- class="ms-1 shrink-0"
282
- >
283
- {{ t('contextAttachments.importsOnAdd') }}
284
- </UBadge>
285
- <button
286
- type="button"
287
- class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
288
- @click="removePending(item)"
280
+ <div class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-slate-300">
281
+ <UIcon
282
+ :name="item.icon ?? 'i-lucide-square-check'"
283
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
284
+ />
285
+ <span class="truncate">{{ item.title }}</span>
286
+ <UBadge
287
+ v-if="item.needsImport"
288
+ color="neutral"
289
+ variant="soft"
290
+ size="xs"
291
+ class="ms-1 shrink-0"
292
+ >
293
+ {{ t('contextAttachments.importsOnAdd') }}
294
+ </UBadge>
295
+ <button
296
+ type="button"
297
+ class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
298
+ @click="removePending(item)"
299
+ >
300
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
301
+ </button>
302
+ </div>
303
+ <!-- An issue reference gets no pre-flight of its own (there is no `parseRef` to ask a
304
+ tracker), so this line IS its warning: the fetch is attempted when the form opens and
305
+ again on submit, and a failure now blocks the create. -->
306
+ <p
307
+ v-if="item.unreadable"
308
+ class="px-2 pb-1.5 text-[11px] text-amber-400"
309
+ data-testid="context-item-unreadable"
289
310
  >
290
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
291
- </button>
311
+ {{ t('contextAttachments.unreadable', { error: item.unreadable }) }}
312
+ </p>
292
313
  </div>
293
314
  </div>
294
315
  <p v-else class="text-[11px] text-slate-500">
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ResolvedDocumentRef } from '@cat-factory/contracts'
3
+ import { ApiError } from '~/composables/api/errors'
4
+ import {
5
+ classifyRefFailure,
6
+ refCandidateOf,
7
+ refRowFor,
8
+ type RefState,
9
+ } from '~/components/documents/ContextDocumentPicker.logic'
10
+
11
+ // The picker used to stage whatever text sat in its input, so a Figma share link (title segment
12
+ // plus `?p=`/`&t=` params) was accepted verbatim and a link the source could not read at all was
13
+ // accepted just as readily, the verdict arriving as a failed import after the task was created.
14
+ // These three functions are what makes the verdict arrive first, and each is pinned here.
15
+
16
+ /** The refusal envelope the resolve endpoint answers with, as a thrown `ApiError`. */
17
+ function refusal(details: Record<string, unknown>): ApiError {
18
+ return new ApiError(422, { error: { code: 'validation', message: 'nope', details } })
19
+ }
20
+
21
+ describe('refCandidateOf', () => {
22
+ it('treats any non-empty text as a reference for a source with no catalogue search', () => {
23
+ // Pasting is the only way to attach a page to such a source, so a bare id must resolve.
24
+ expect(refCandidateOf(' 6k0gqOC6ppDMAziCmZ2Gv9 ', false)).toBe('6k0gqOC6ppDMAziCmZ2Gv9')
25
+ expect(refCandidateOf(' ', false)).toBeNull()
26
+ })
27
+
28
+ it('leaves a plain search phrase alone on a searchable source', () => {
29
+ // Resolving every keystroke of a title search would render a refusal at someone who is
30
+ // simply searching, which is the opposite of the point.
31
+ expect(refCandidateOf('export requirements', true)).toBeNull()
32
+ expect(refCandidateOf('https://www.figma.com/design/K/T?node-id=1-2', true)).toBe(
33
+ 'https://www.figma.com/design/K/T?node-id=1-2',
34
+ )
35
+ // A link with the scheme pasted off is still a link.
36
+ expect(refCandidateOf('notion.so/Checkout-PRD-1f2e3d', true)).toBe(
37
+ 'notion.so/Checkout-PRD-1f2e3d',
38
+ )
39
+ })
40
+
41
+ it('does not read a PHRASE as a reference just because it carries punctuation', () => {
42
+ // The consequence of a false positive changed with this surface: it used to cost an ignorable
43
+ // extra row, and now it renders "Not a Notion reference" in amber above the results for that
44
+ // very phrase. Whitespace is the tell, so `/` and `#` inside a title search no longer qualify.
45
+ expect(refCandidateOf('auth/login flow', true)).toBeNull()
46
+ expect(refCandidateOf('sprint #4 plan', true)).toBeNull()
47
+ expect(refCandidateOf('roadmap Q3/Q4', true)).toBeNull()
48
+ })
49
+
50
+ it('accepts the bare id shapes no title could be confused with', () => {
51
+ // Worth ASKING about (the backend stays the judge): a Notion id dashed or dashless, and a
52
+ // Confluence page id. A single unrecognised word is a search, not an id.
53
+ expect(refCandidateOf('1f2e3d4c5b6a78901234567890abcdef', true)).toBe(
54
+ '1f2e3d4c5b6a78901234567890abcdef',
55
+ )
56
+ expect(refCandidateOf('1f2e3d4c-5b6a-7890-1234-567890abcdef', true)).toBe(
57
+ '1f2e3d4c-5b6a-7890-1234-567890abcdef',
58
+ )
59
+ expect(refCandidateOf('123456', true)).toBe('123456')
60
+ expect(refCandidateOf('authentication', true)).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('classifyRefFailure', () => {
65
+ it('reads the two refusal reasons and the detail each correction needs', () => {
66
+ expect(
67
+ classifyRefFailure(
68
+ refusal({ reason: 'document_ref_claimed_by_other_source', claimedBy: 'figma' }),
69
+ ),
70
+ ).toEqual({
71
+ status: 'rejected',
72
+ reason: 'document_ref_claimed_by_other_source',
73
+ claimedBy: 'figma',
74
+ })
75
+
76
+ expect(
77
+ classifyRefFailure(refusal({ reason: 'document_ref_unrecognized', expected: 'https://…' })),
78
+ ).toEqual({ status: 'rejected', reason: 'document_ref_unrecognized', expected: 'https://…' })
79
+ })
80
+
81
+ it('leaves the reference UNCHECKED when the call failed rather than the source refusing', () => {
82
+ // A 502, an offline browser or a proxy's own error page says nothing about the link. Reading
83
+ // any error as a refusal would send the user off to fix a link that was fine.
84
+ expect(classifyRefFailure(new Error('Failed to fetch'))).toEqual({
85
+ status: 'unchecked',
86
+ message: 'Failed to fetch',
87
+ })
88
+ expect(classifyRefFailure(new ApiError(502, '<html>bad gateway</html>')).status).toBe(
89
+ 'unchecked',
90
+ )
91
+ })
92
+
93
+ it('does not trust a reason outside the contract vocabulary', () => {
94
+ // An older or newer backend can name a reason this build has no copy for; rendering it as a
95
+ // refusal would show the user a blank explanation for a link that may be perfectly good.
96
+ expect(classifyRefFailure(refusal({ reason: 'something_new' })).status).toBe('unchecked')
97
+ })
98
+ })
99
+
100
+ describe('refRowFor', () => {
101
+ const resolved = {
102
+ source: 'figma' as const,
103
+ externalId: '6k0gqOC6ppDMAziCmZ2Gv9:5765:57229',
104
+ canonicalUrl: 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9?node-id=5765-57229',
105
+ droppedScope: null,
106
+ }
107
+ const ok = (ref: ResolvedDocumentRef): RefState => ({ status: 'ok', ref })
108
+
109
+ it('labels the row with the CANONICAL form and flags that the paste was trimmed', () => {
110
+ const row = refRowFor(
111
+ ok(resolved),
112
+ 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9/Project-Redwood--Autopilot-AI-' +
113
+ '?node-id=5765-57229&p=f&t=J1SrKp6sgJm9CIeQ-0',
114
+ )
115
+ expect(row).toEqual({
116
+ externalId: resolved.externalId,
117
+ source: 'figma',
118
+ canonicalUrl: resolved.canonicalUrl,
119
+ label: resolved.canonicalUrl,
120
+ trimmed: true,
121
+ droppedScope: null,
122
+ unchecked: false,
123
+ })
124
+ })
125
+
126
+ it('does not claim a trim when the canonical form is what was pasted', () => {
127
+ expect(refRowFor(ok(resolved), ` ${resolved.canonicalUrl} `)).toMatchObject({ trimmed: false })
128
+ })
129
+
130
+ it('carries a WIDENED reference separately from a trim', () => {
131
+ // The two are opposite facts wearing the same clothes. A trim resolves the same page; dropping
132
+ // an unreadable `node-id` swaps one frame for the whole design file, and the label alone cannot
133
+ // show it (a whole-file canonical URL looks perfectly well-formed). A row that reported only
134
+ // `trimmed` here is how "I attached this frame" became "the agent read everything".
135
+ const widened = {
136
+ source: 'figma' as const,
137
+ externalId: '6k0gqOC6ppDMAziCmZ2Gv9',
138
+ canonicalUrl: 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9',
139
+ droppedScope: 'I2649:14930;2649:14746',
140
+ }
141
+ const row = refRowFor(
142
+ ok(widened),
143
+ 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9/R?node-id=I2649:14930;2649:14746',
144
+ )
145
+ expect(row).toMatchObject({ trimmed: true, droppedScope: 'I2649:14930;2649:14746' })
146
+ })
147
+
148
+ it('prefers the imported page title, and falls back to the id when there is no URL to show', () => {
149
+ expect(refRowFor(ok(resolved), resolved.canonicalUrl, 'Autopilot Home')?.label).toBe(
150
+ 'Autopilot Home',
151
+ )
152
+ // A Confluence page id cannot be rendered back as a URL without the site base, so the id
153
+ // itself is the canonical form, not a missing one.
154
+ const noUrl = {
155
+ source: 'confluence' as const,
156
+ externalId: '4567',
157
+ canonicalUrl: null,
158
+ droppedScope: null,
159
+ }
160
+ expect(refRowFor(ok(noUrl), '4567')).toMatchObject({ label: '4567', trimmed: false })
161
+ })
162
+
163
+ it('still offers an UNJUDGED reference, carrying the pasted text', () => {
164
+ // "The source refused this" and "we could not ask" are different facts, and only the first is
165
+ // evidence against a link. Suppressing the row for the second made a transient 502 or an
166
+ // offline moment as final as a refusal: attaching a perfectly good link became impossible,
167
+ // where the import had always been the backstop.
168
+ const row = refRowFor({ status: 'unchecked', message: 'Failed to fetch' }, ' notion.so/abc ')
169
+ expect(row).toEqual({
170
+ externalId: 'notion.so/abc',
171
+ // Only the resolve answers which source claims a paste; the picker supplies the selected one.
172
+ source: null,
173
+ canonicalUrl: null,
174
+ label: 'notion.so/abc',
175
+ trimmed: false,
176
+ droppedScope: null,
177
+ unchecked: true,
178
+ })
179
+ })
180
+
181
+ it('offers nothing while checking, or once the source has refused', () => {
182
+ expect(refRowFor({ status: 'none' }, 'x')).toBeNull()
183
+ expect(refRowFor({ status: 'checking' }, 'x')).toBeNull()
184
+ expect(refRowFor({ status: 'rejected', reason: 'document_ref_unrecognized' }, 'x')).toBeNull()
185
+ })
186
+ })