From ec0ad5bf9980ff466d453a3c6996291d77264d13 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:23:29 +0800 Subject: [PATCH 1/3] chore(format): ignore and untrack machine-local hook cache `.impeccable/` is a tooling hook cache (session ids, timestamps, local absolute paths) that was committed by mistake and fails `prettier --check`. Add it to .prettierignore and .gitignore, and untrack the committed file. (`database.types.ts` is already covered by .prettierignore on main.) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +++ .impeccable/hook.cache.json | 1 - .prettierignore | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) delete mode 100644 .impeccable/hook.cache.json diff --git a/.gitignore b/.gitignore index 25e923da98..19d9626bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ scratch/ .qa-smoke/ *.pid tmp_output.txt + +# machine-local tooling hook cache (session ids, timestamps, local paths) +.impeccable/ diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json deleted file mode 100644 index a2d4e16581..0000000000 --- a/.impeccable/hook.cache.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"sessions":{"70c96a73-49a6-4422-a310-d0294a45dc49":{"updatedAt":1782977742215,"files":{"C:\\Users\\joshs\\.copilot\\repos\\copilot-worktrees\\Database\\bigsimmo-bookish-barnacle\\supabase\\functions\\indexing-v3-agent\\index.ts":{"editCount":1,"findings":[]}}}}} \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index fa1ce8a5ae..ebda744c78 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,6 @@ scratch/ # Generated by `supabase gen types`; keep the generator's formatting so # regeneration stays churn-free. src/lib/supabase/database.types.ts + +# Machine-local tooling hook cache (session ids, timestamps, local paths). +.impeccable/ From 8822c2033e997ae257f20cfd744c1be5a30221a8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:25:02 +0800 Subject: [PATCH 2/3] style: apply prettier to source files failing format:check Mechanical `prettier --write .` of the tracked files that were not prettier-clean on current main. Formatting only, no logic changes. Co-Authored-By: Claude Fable 5 --- ...7-02-triage-security-reliability-design.md | 7 +++++++ src/app/api/documents/[id]/reindex/route.ts | 12 +++++++++--- src/app/api/documents/bulk/reindex/route.ts | 8 ++++++-- src/app/api/upload/route.ts | 8 ++++++-- src/components/ClinicalDashboard.tsx | 3 +-- src/components/applications-launcher-page.tsx | 4 +--- .../global-mockup-search-shell.tsx | 8 +++++++- .../medication-prescribing-workspace.tsx | 9 +-------- src/lib/privacy.ts | 18 ++++++++++-------- src/lib/supabase/auth.ts | 1 - src/lib/supabase/client.tsx | 1 - tests/embedding-dimensions.test.ts | 4 +++- tests/forms-clipboard-fallback.test.ts | 2 +- tests/private-client-auth.test.ts | 2 -- tests/supabase-schema.test.ts | 4 +++- tests/worker-visual-capture.test.ts | 2 +- worker/main.ts | 19 ++++++++++++------- 17 files changed, 68 insertions(+), 44 deletions(-) diff --git a/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md b/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md index e586e7e852..16b5958357 100644 --- a/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md +++ b/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md @@ -6,6 +6,7 @@ Scope: Issue #53 (security), #55 (reliability gaps only), #56 (no change unless ## 1. Goals and non-goals ### Goals + - Remove authentication bypass behavior so protected APIs fail closed. - Stop client-side reliance on persisted localStorage auth token hints. - Redact high-risk identifiers from generated image captions before persistence. @@ -13,6 +14,7 @@ Scope: Issue #53 (security), #55 (reliability gaps only), #56 (no change unless - Close currently open reliability gaps from #55 with minimal, targeted changes (especially embedding-dimension drift safeguards). ### Non-goals + - No front-end refactor work from #51. - No performance-oriented search/ingestion optimization from #52. - No broad operational redesign for #56; only patch ops docs/scripts if implementation reveals a concrete missing step. @@ -32,16 +34,19 @@ This is preferred over broader rewrites because it directly addresses active ris ## 3. Architecture and component changes ### 3.1 Authentication boundary + - Primary file: `src/lib/supabase/auth.ts`. - Change: remove environment-based no-auth fallback paths for protected API authorization. - Result: auth gate requires valid Supabase-authenticated user identity; invalid/missing identity returns explicit unauthorized response paths. ### 3.2 Client-side session handling + - Primary files: `src/lib/supabase/client.tsx`, `src/components/ClinicalDashboard.tsx`. - Change: stop relying on localStorage token presence scans and persisted auth-email hints for deciding private API/session capability. - Result: UI behavior follows actual Supabase session/auth state only. ### 3.3 Caption redaction before persistence + - Primary file: `worker/main.ts` (with helper placement in shared privacy utility area where appropriate). - Change: sanitize generated caption text before database/cache writes. - Baseline redaction targets: @@ -51,11 +56,13 @@ This is preferred over broader rewrites because it directly addresses active ris - Result: ingestion remains functional, but persisted captions are safer by default. ### 3.4 Safe Supabase logging + - Primary files: worker/server/script callsites identified during implementation. - Change: route Supabase-related error detail formatting through existing safe redaction utilities instead of direct raw detail logging. - Result: operational logs remain actionable without leaking secret/token/identifier content. ### 3.5 Embedding-dimension drift safeguards + - Primary file: `src/lib/embedding-dimensions.ts` and nearby ingestion assertions/tests. - Change: align expected dimension checks to a single configuration source used by ingestion-time assertions. - Result: mismatches fail fast and predictably, avoiding silent search-quality corruption. diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index f2a05b0f74..32057ab417 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -115,7 +115,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { data: document, error: documentError } = await supabase .from("documents") - .select("id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata") + .select( + "id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata", + ) .eq("id", id) .eq("owner_id", user.id) .maybeSingle(); @@ -240,7 +242,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .in("status", ["pending", "processing"]) .limit(1); if (competingJobsError) { - throw new Error(`Failed to enqueue reindex job: ${jobError.message}; competing-job check failed: ${competingJobsError.message}`); + throw new Error( + `Failed to enqueue reindex job: ${jobError.message}; competing-job check failed: ${competingJobsError.message}`, + ); } if ((competingJobs?.length ?? 0) === 0) { const { error: rollbackError } = await supabase @@ -250,7 +254,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .eq("owner_id", user.id) .eq("updated_at", rollbackFence); if (rollbackError) { - throw new Error(`Failed to enqueue reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`); + throw new Error( + `Failed to enqueue reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`, + ); } } throw new Error(jobError.message); diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 06391bfcb6..15d3270121 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -105,7 +105,9 @@ export async function POST(request: Request) { const documentIds = Array.from(new Set(parsed.documentIds)); const { data: documents, error: documentError } = await supabase .from("documents") - .select("id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata") + .select( + "id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata", + ) .eq("owner_id", user.id) .in("id", documentIds); if (documentError) throw new Error(documentError.message); @@ -244,7 +246,9 @@ export async function POST(request: Request) { .eq("owner_id", user.id) .eq("updated_at", rollbackFence); if (rollbackError) { - throw new Error(`Failed to enqueue bulk reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`); + throw new Error( + `Failed to enqueue bulk reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`, + ); } } throw new Error(jobError.message); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 36dff1c2d4..7aacea2c03 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -166,7 +166,9 @@ export async function POST(request: Request) { .eq("id", documentId) .eq("owner_id", user.id); if (rollbackDocumentError) { - throw new Error(`Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`); + throw new Error( + `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, + ); } insertedDocumentId = null; insertedDocumentOwnerId = null; @@ -208,7 +210,9 @@ export async function POST(request: Request) { if (uploadedPath && supabase) { try { - const { error: cleanupStorageError } = await supabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).remove([uploadedPath]); + const { error: cleanupStorageError } = await supabase.storage + .from(env.SUPABASE_DOCUMENT_BUCKET) + .remove([uploadedPath]); if (cleanupStorageError) { logger.error("Upload cleanup failed; storage object may be orphaned", { storagePath: uploadedPath, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 02172e1ae9..7276ea2614 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5778,8 +5778,7 @@ export function ClinicalDashboard({ demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); const canUsePrivateApis = - localProjectReady && - (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); + localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "mobileSidebar" | "documents" | "upload") => { diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index dcfca0b373..9406747327 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -934,9 +934,7 @@ export function ApplicationsLauncherWorkspace({ ); } - return ( -
{workspace}
- ); + return
{workspace}
; } export function ApplicationsLauncherPage() { diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index a05e1387b5..20058df260 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -16,7 +16,13 @@ import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; import { cn } from "@/components/ui-primitives"; -import { appModeHomeHref, isAppModeId, isAppModeVisible, visibleAppModeDefinitions, type AppModeId } from "@/lib/app-modes"; +import { + appModeHomeHref, + isAppModeId, + isAppModeVisible, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 94cb8c28d5..557d176b25 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -27,14 +27,7 @@ import Link from "next/link"; import { useState } from "react"; import { ModeHomeTemplate } from "@/components/mode-home-template"; -import { - cn, - toneDanger, - toneInfo, - toneNeutral, - toneSuccess, - toneWarning, -} from "@/components/ui-primitives"; +import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { query: string; diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 17f4595dab..78d4727065 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -18,14 +18,16 @@ function redactLogValue(value: unknown): unknown { } const htmlTitle = value.match(/\s*([^<]+?)\s*<\/title>/i)?.[1]?.trim(); const normalizedValue = htmlTitle ? `HTML response: ${htmlTitle}` : value; - return normalizedValue - .replace(/\b[A-Za-z]:\\[^\s'\")]+/g, "[path]") - .replace(/\/(?:[^\s'\")]+\/)+[^\s'\")]+/g, "[path]") - .replace(/https?:\/\/[^\s'\")]+/g, "[url]") - // Redact common secret/token formats, including modern Supabase keys like sb_secret_ and sb_publishable_ - .replace(/\b(?:sk|pk|sbp|sb_secret_|sb_publishable_|eyJ)[A-Za-z0-9._-]{8,}\b/g, "[secret]") - .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") - .slice(0, 500); + return ( + normalizedValue + .replace(/\b[A-Za-z]:\\[^\s'\")]+/g, "[path]") + .replace(/\/(?:[^\s'\")]+\/)+[^\s'\")]+/g, "[path]") + .replace(/https?:\/\/[^\s'\")]+/g, "[url]") + // Redact common secret/token formats, including modern Supabase keys like sb_secret_ and sb_publishable_ + .replace(/\b(?:sk|pk|sbp|sb_secret_|sb_publishable_|eyJ)[A-Za-z0-9._-]{8,}\b/g, "[secret]") + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") + .slice(0, 500) + ); } export function safeErrorLogDetails(error: unknown) { diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 721c4a7dd4..e408be34a6 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -81,4 +81,3 @@ export async function requireAuthenticatedUser(request: Request, supabase: Admin return { id: userId }; } - diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 905638996f..aa20e8e10f 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -272,4 +272,3 @@ export function useAuthSession() { } return context; } - diff --git a/tests/embedding-dimensions.test.ts b/tests/embedding-dimensions.test.ts index 6641fe5889..c35a4f5970 100644 --- a/tests/embedding-dimensions.test.ts +++ b/tests/embedding-dimensions.test.ts @@ -22,7 +22,9 @@ describe("strict embedding dimension guard", () => { const { EXPECTED_EMBED_DIM, assertEmbeddingDim } = await import("../src/lib/embedding-dimensions"); expect(() => assertEmbeddingDim("not-a-vector", "test_vector")).toThrow(/must be an array/); - expect(() => assertEmbeddingDim([0.1, 0.2], "test_vector")).toThrow(new RegExp(`2 dimensions; expected ${EXPECTED_EMBED_DIM}`)); + expect(() => assertEmbeddingDim([0.1, 0.2], "test_vector")).toThrow( + new RegExp(`2 dimensions; expected ${EXPECTED_EMBED_DIM}`), + ); expect(() => assertEmbeddingDim([...Array.from({ length: EXPECTED_EMBED_DIM - 1 }, () => 0), Infinity], "test_vector"), ).toThrow(new RegExp(`non-finite value at index ${EXPECTED_EMBED_DIM - 1}`)); diff --git a/tests/forms-clipboard-fallback.test.ts b/tests/forms-clipboard-fallback.test.ts index a41b963293..a1e2f7dce7 100644 --- a/tests/forms-clipboard-fallback.test.ts +++ b/tests/forms-clipboard-fallback.test.ts @@ -8,7 +8,7 @@ describe("form detail clipboard fallback", () => { expect(source).toContain("if (navigator.clipboard?.writeText)"); expect(source).toContain("await navigator.clipboard.writeText(value)"); expect(source).toContain("Fall through to the legacy selection path for restricted browser contexts."); - expect(source).toContain("document.execCommand?.(\"copy\")"); + expect(source).toContain('document.execCommand?.("copy")'); expect(source).toContain("finally {\n document.body.removeChild(textArea);\n }"); }); }); diff --git a/tests/private-client-auth.test.ts b/tests/private-client-auth.test.ts index 6a5ca93cc0..b23444e466 100644 --- a/tests/private-client-auth.test.ts +++ b/tests/private-client-auth.test.ts @@ -26,5 +26,3 @@ describe("browser auth helpers", () => { expect(isUsableBrowserSupabaseKey("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature")).toBe(true); }); }); - - diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index ee94693723..d9d7d05561 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -271,7 +271,9 @@ describe("Supabase schema Data API grants", () => { expect(schema).toContain("alter table public.ingestion_job_stages enable row level security"); expect(schema).toContain('create policy "ingestion job stages service role all" on public.ingestion_job_stages'); expect(schema).toContain("alter table public.indexing_v3_agent_jobs enable row level security"); - expect(schema).toContain('create policy "indexing v3 agent jobs service role all" on public.indexing_v3_agent_jobs'); + expect(schema).toContain( + 'create policy "indexing v3 agent jobs service role all" on public.indexing_v3_agent_jobs', + ); const authenticatedSelectGrant = schema.match(/grant select on table ([^;]+) to authenticated;/)?.[1] ?? ""; expect(authenticatedSelectGrant).not.toContain("public.ingestion_job_stages"); expect(authenticatedSelectGrant).not.toContain("public.indexing_v3_agent_jobs"); diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index e155820abe..1e61e0f100 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -75,7 +75,7 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain("let classification = redactImageClassification(resolved.classification);"); expect(workerSource).toContain("caption: classification.caption"); expect(workerSource).toContain( - 'const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), {', + "const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), {", ); }); diff --git a/worker/main.ts b/worker/main.ts index 922dfc62eb..29cd7d21d3 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -752,7 +752,9 @@ async function getCachedImageClassification(ownerId: string | null, imageHash: s : "unclear"; const score = Number(metadata.clinical_relevance_score); const labels = cachedImageLabels(metadata.labels); - const cachedCaption = redactCaptionIdentifiers(cleanString(String(data.caption || "").trim() || "Extracted source image.")); + const cachedCaption = redactCaptionIdentifiers( + cleanString(String(data.caption || "").trim() || "Extracted source image."), + ); const assessment = assessClinicalImageUse({ imageType, searchable: Boolean(metadata.searchable), @@ -761,10 +763,13 @@ async function getCachedImageClassification(ownerId: string | null, imageHash: s labels, skipReason: typeof metadata.skip_reason === "string" ? metadata.skip_reason : null, }); - const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), { - fallbackText: cachedCaption, - fallbackConfidence: score, - }); + const structuredProfile = normalizeStructuredVisualProfile( + redactCaptionMetadataValue(metadata.structured_visual_profile), + { + fallbackText: cachedCaption, + fallbackConfidence: score, + }, + ); return { image_type: imageType, @@ -813,8 +818,8 @@ async function setCachedImageClassification(args: { clinical_signal_score: classification.clinical_signal_score, admin_signal_score: classification.admin_signal_score, structured_visual_profile: (classification as ImageClassificationWithVisualProfile).structured_visual_profile, - structured_extraction_confidence: - (classification as ImageClassificationWithVisualProfile).structured_extraction_confidence, + structured_extraction_confidence: (classification as ImageClassificationWithVisualProfile) + .structured_extraction_confidence, image_policy_version: clinicalImagePolicyVersion, visual_intelligence_version: visualIntelligenceVersion, image_caption_cache_version: imageCaptionCacheVersion, From 517fe8069217255737c0be28a39a1a7d63279dc5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:55:19 +0800 Subject: [PATCH 3/3] test: make worker source-guard assertion formatting-agnostic `prettier --write` wraps the long normalizeStructuredVisualProfile(...) call in worker/main.ts across multiple lines, which broke the exact single-line `toContain` assertion in worker-visual-capture.test.ts. Match two stable substrings instead so the caption-redaction guard survives prettier formatting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/worker-visual-capture.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index 1e61e0f100..5b90343d6e 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -74,9 +74,8 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain("const classification = redactImageClassification(args.classification);"); expect(workerSource).toContain("let classification = redactImageClassification(resolved.classification);"); expect(workerSource).toContain("caption: classification.caption"); - expect(workerSource).toContain( - "const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), {", - ); + expect(workerSource).toContain("const structuredProfile = normalizeStructuredVisualProfile("); + expect(workerSource).toContain("redactCaptionMetadataValue(metadata.structured_visual_profile)"); }); it("computes perceptual duplicate groups before caption budget selection", () => {