diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 50b0489a8..e949aaeb5 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,16 +18,18 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | ------------------------------------- | ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | -| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | -| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | -| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | -------------------------------------- | ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | +| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | +| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | +| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | +| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | diff --git a/playwright.config.ts b/playwright.config.ts index 7fe7ce2dd..7ac060372 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,8 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; export default defineConfig({ testDir: "./tests", - testMatch: /.*ui-(smoke|stress|accessibility|tools|tools-task-directory|overlap|universal-search)\.spec\.ts/, + testMatch: + /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search)\.spec\.ts/, timeout: 60_000, retries: process.env.CI ? 1 : 0, expect: { diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts index 75fc217a4..f146315f7 100644 --- a/src/app/api/documents/[id]/table-facts/route.ts +++ b/src/app/api/documents/[id]/table-facts/route.ts @@ -93,6 +93,21 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); } + let sourceImage: { id: string; metadata: unknown } | null = null; + if (fact.source_image_id) { + const { data: image, error: imageError } = await supabase + .from("document_images") + .select("id,metadata") + .eq("id", fact.source_image_id) + .eq("document_id", id) + .maybeSingle(); + if (imageError) throw new Error(imageError.message); + if (image && !isCommittedGenerationMetadata({ rowMetadata: image.metadata, committedGeneration })) { + return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); + } + sourceImage = image; + } + const reviewMetadata = tableReviewMetadata({ reviewClass: parsed.reviewClass, notes: parsed.notes, @@ -109,26 +124,15 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (updateError) throw new Error(updateError.message); - if (fact.source_image_id) { - const { data: image } = await supabase + if (sourceImage && fact.source_image_id) { + const { error: imageUpdateError } = await supabase .from("document_images") - .select("id,metadata") - .eq("id", fact.source_image_id) - .eq("document_id", id) - .maybeSingle(); - if (image) { - if (!isCommittedGenerationMetadata({ rowMetadata: image.metadata, committedGeneration })) { - return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); - } - const { error: imageUpdateError } = await supabase - .from("document_images") - .update({ - metadata: { ...metadataRecord(image.metadata), ...reviewMetadata }, - searchable: parsed.reviewClass === "clinical_useful" || parsed.reviewClass === "reference", - }) - .eq("id", fact.source_image_id); - if (imageUpdateError) throw new Error(imageUpdateError.message); - } + .update({ + metadata: { ...metadataRecord(sourceImage.metadata), ...reviewMetadata }, + searchable: parsed.reviewClass === "clinical_useful" || parsed.reviewClass === "reference", + }) + .eq("id", fact.source_image_id); + if (imageUpdateError) throw new Error(imageUpdateError.message); } invalidateRagCachesForOwner(user.id); diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index c94551a28..dcece4aa6 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -31,8 +31,10 @@ const bulkMetadataSchema = z.object({ .default({}), titleEdit: z .object({ - prefix: z.string().trim().max(40).optional(), - suffix: z.string().trim().max(40).optional(), + // Preserve intentional spacing at the title boundary. `editTitle` trims the + // completed title, so outer whitespace is still discarded safely. + prefix: z.string().max(40).optional(), + suffix: z.string().max(40).optional(), find: z.string().trim().max(80).optional(), replace: z.string().trim().max(80).optional(), }) diff --git a/src/app/api/ingestion/quality/route.ts b/src/app/api/ingestion/quality/route.ts index 6a8d7d486..7a888ccbd 100644 --- a/src/app/api/ingestion/quality/route.ts +++ b/src/app/api/ingestion/quality/route.ts @@ -115,6 +115,19 @@ function tableRowsFromMetadata(metadata: Record) { return Array.isArray(rows) ? rows.length : 0; } +function groupRowsByDocument(rows: Row[]) { + const groupedRows = new Map(); + for (const row of rows) { + const documentRows = groupedRows.get(row.document_id); + if (documentRows) { + documentRows.push(row); + } else { + groupedRows.set(row.document_id, [row]); + } + } + return groupedRows; +} + function itemPriority(item: ReviewItem) { const severityRank = item.severity === "danger" ? 0 : item.severity === "warning" ? 1 : 2; const typeRank: Record = { @@ -137,19 +150,10 @@ function buildReviewItems(args: { images: ImageRow[]; }) { const qualityByDocument = new Map(args.qualityRows.map((row) => [row.document_id, row])); - const jobsByDocument = new Map(); - const stagesByDocument = new Map(); - const pagesByDocument = new Map(); - const imagesByDocument = new Map(); - - for (const row of args.jobs) - jobsByDocument.set(row.document_id, [...(jobsByDocument.get(row.document_id) ?? []), row]); - for (const row of args.stages) - stagesByDocument.set(row.document_id, [...(stagesByDocument.get(row.document_id) ?? []), row]); - for (const row of args.pages) - pagesByDocument.set(row.document_id, [...(pagesByDocument.get(row.document_id) ?? []), row]); - for (const row of args.images) - imagesByDocument.set(row.document_id, [...(imagesByDocument.get(row.document_id) ?? []), row]); + const jobsByDocument = groupRowsByDocument(args.jobs); + const stagesByDocument = groupRowsByDocument(args.stages); + const pagesByDocument = groupRowsByDocument(args.pages); + const imagesByDocument = groupRowsByDocument(args.images); const items: ReviewItem[] = []; const pushItem = (document: DocumentRow, item: Omit) => { diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index aab12a9d0..1a1d9341a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -128,6 +128,7 @@ import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, classifyAnswerError, + isAnswerPayload, isRetryableError, isRetryableMessage, isRetryableStatus, @@ -346,7 +347,6 @@ async function readAnswerStream( const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - let finalPayload: AnswerPayload | null = null; function processEvent(block: string) { const lines = block.split(/\r?\n/); @@ -398,11 +398,13 @@ async function readAnswerStream( ); } if (event === "final") { - finalPayload = data as AnswerPayload; - return true; + if (!isAnswerPayload(data)) { + throw makeSearchError("Answer stream returned an invalid final payload.", 502, true); + } + return data; } - return false; + return null; } while (true) { @@ -413,9 +415,10 @@ async function readAnswerStream( while (separator) { const block = buffer.slice(0, separator.index).trim(); buffer = buffer.slice(separator.index + separator.length); - if (block && processEvent(block) && finalPayload) { + const finalPayload = block ? processEvent(block) : null; + if (finalPayload) { await reader.cancel().catch(() => undefined); - return finalPayload as AnswerPayload; + return finalPayload; } separator = findSseSeparator(buffer); } @@ -423,9 +426,9 @@ async function readAnswerStream( if (done) break; } - if (buffer.trim() && processEvent(buffer.trim()) && finalPayload) return finalPayload as AnswerPayload; - if (!finalPayload) throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); - return finalPayload as AnswerPayload; + const finalPayload = buffer.trim() ? processEvent(buffer.trim()) : null; + if (finalPayload) return finalPayload; + throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); } // Provisional view shown while an answer streams in. The prose is content-preserving (the same diff --git a/src/components/clinical-dashboard/search-utils.ts b/src/components/clinical-dashboard/search-utils.ts index 198c57afa..2b37fc03a 100644 --- a/src/components/clinical-dashboard/search-utils.ts +++ b/src/components/clinical-dashboard/search-utils.ts @@ -4,6 +4,21 @@ export { keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; export type AnswerPayload = RagAnswer & { demoMode?: boolean }; +const answerConfidenceValues = new Set(["high", "medium", "low", "unsupported"]); + +export function isAnswerPayload(value: unknown): value is AnswerPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const payload = value as Record; + return ( + typeof payload.answer === "string" && + typeof payload.grounded === "boolean" && + answerConfidenceValues.has(payload.confidence as AnswerPayload["confidence"]) && + Array.isArray(payload.citations) && + Array.isArray(payload.sources) && + (payload.demoMode === undefined || typeof payload.demoMode === "boolean") + ); +} + export type SearchError = Error & { status?: number; retryable?: boolean; diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index efbb200b6..7889ea09e 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -209,8 +209,8 @@ function CommandDropdown({ onMouseEnter={() => onHoverItem(item.id)} onMouseDown={(event) => { event.preventDefault(); - item.onSelect(); }} + onClick={item.onSelect} className="cursor-pointer" > {item.render(activeItemId === item.id)} @@ -227,8 +227,8 @@ function CommandDropdown({ onMouseEnter={() => onHoverItem(item.id)} onMouseDown={(event) => { event.preventDefault(); - item.onSelect(); }} + onClick={item.onSelect} className="cursor-pointer" > {item.render(activeItemId === item.id)} @@ -502,7 +502,7 @@ export function UniversalSearchCommandSurface({ label: action.label, onSelect: () => { onDropdownOpenChange(false); - router.push(action.href); + onCrossMode("answer", trimmedQuery); }, render: (active) => ( diff --git a/src/components/tools-page-mockups/tools-page-mockup-page.tsx b/src/components/tools-page-mockups/tools-page-mockup-page.tsx index 69fa1c1c6..74e5bab1e 100644 --- a/src/components/tools-page-mockups/tools-page-mockup-page.tsx +++ b/src/components/tools-page-mockups/tools-page-mockup-page.tsx @@ -18,6 +18,7 @@ import { ShieldCheck, Smartphone, Sparkles, + Star, Stethoscope, X, type LucideIcon, @@ -821,28 +822,30 @@ function CommandCenterMockup() { -
-
-
-
-

Start here

-

- Most-used tools stay one click away. -

+ {!filter.isFiltering && ( +
+
+
+
+

Start here

+

+ Most-used tools stay one click away. +

+
-
-
- {pinned.map((tool) => ( - - ))} -
-
+
+ {pinned.map((tool) => ( + + ))} +
+
- - + + + )} - + {!filter.isFiltering && } ); @@ -903,99 +906,109 @@ function WorkflowBoardMockup() {
-
-
- - - - -
+ {!filter.isFiltering && ( +
+
+ + + + + +
-
+ +
+ + + )} - + {!filter.isFiltering && }
); @@ -1073,19 +1086,47 @@ function SplitPaneMockup() {
-
-
-

- Filters sit beside the overview, while the full-width All tools view below carries the main browsing - weight. -

-
- {["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"].map((id) => ( - - ))} -
+ {filter.isFiltering ? ( + <> +
+
+
+

+ Showing {filter.filtered.length} of {tools.length} +

+
+ {filter.filtered.length > 0 ? ( +
+ {filter.filtered.map((tool) => ( + + ))} +
+ ) : ( + + )} + + ) : ( + <> +
+
+

+ Filters sit beside the overview, while the full-width All tools view below carries the main browsing + weight. +

+
+ {["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"].map((id) => ( + + ))} +
+ + )}
@@ -1099,20 +1140,22 @@ function SplitPaneMockup() { /> - setSelectedToolId(tool.id)} - /> + {!filter.isFiltering && ( + setSelectedToolId(tool.id)} + /> + )} - + {!filter.isFiltering && } ); diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index 93ce5224b..59c25e538 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -6,8 +6,38 @@ import ExcelJS from "exceljs"; import mammoth from "mammoth"; import { PDFParse } from "pdf-parse"; import JSZip from "jszip"; +import { z } from "zod"; import type { ExtractedDocument, ExtractedPage } from "@/lib/types"; +const extractedPageSchema = z.object({ + pageNumber: z.number().int().positive(), + text: z.string(), + ocrUsed: z.boolean().optional(), + needsOcr: z.boolean().optional(), +}); + +const extractedImageSchema = z.object({ + pageNumber: z.number().int().positive().nullable(), + path: z.string().min(1), + mimeType: z.string().min(1), + bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).nullable().optional(), + width: z.number().int().positive().nullable().optional(), + height: z.number().int().positive().nullable().optional(), + sourceKind: z.enum(["embedded", "table_crop", "diagram_crop", "page_region", "fallback", "cover_page"]).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const extractedDocumentSchema = z.object({ + pages: z.array(extractedPageSchema), + images: z.array(extractedImageSchema), + warnings: z.array(z.string()).optional(), + temporaryPaths: z.array(z.string()).optional(), +}); + +export function parseExtractedDocumentPayload(raw: string): ExtractedDocument { + return extractedDocumentSchema.parse(JSON.parse(raw)); +} + function runPythonPdfExtractor(filePath: string, outputDir: string) { const scriptPath = path.join(process.cwd(), "worker", "python", "extract_pdf_assets.py"); const outputJsonPath = path.join(outputDir, "extract.json"); @@ -34,7 +64,7 @@ function runPythonPdfExtractor(filePath: string, outputDir: string) { try { const jsonPayload = await readFile(outputJsonPath, "utf8").catch(() => extractJsonFromStdout(stdout)); - resolve(JSON.parse(jsonPayload) as ExtractedDocument); + resolve(parseExtractedDocumentPayload(jsonPayload)); } catch (error) { reject(error); } diff --git a/tests/auth-callback-route.test.ts b/tests/auth-callback-route.test.ts new file mode 100644 index 000000000..de4693b84 --- /dev/null +++ b/tests/auth-callback-route.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +function callbackRequest(query: string) { + return new Request(`https://clinical.example/auth/callback?${query}`); +} + +describe("/auth/callback", () => { + it("exchanges the PKCE code and redirects to a same-origin path", async () => { + const exchangeCodeForSession = vi.fn(async () => ({ error: null })); + vi.doMock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: vi.fn(async () => ({ auth: { exchangeCodeForSession } })), + })); + const { GET } = await import("../src/app/auth/callback/route"); + + const response = await GET(callbackRequest("code=pkce-code&next=%2Fdocuments%3Fq%3Dlithium")); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://clinical.example/documents?q=lithium"); + expect(exchangeCodeForSession).toHaveBeenCalledWith("pkce-code"); + }); + + it.each(["https://evil.example/steal", "//evil.example/steal"])("rejects unsafe redirect target %s", async (next) => { + const exchangeCodeForSession = vi.fn(async () => ({ error: null })); + vi.doMock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: vi.fn(async () => ({ auth: { exchangeCodeForSession } })), + })); + const { GET } = await import("../src/app/auth/callback/route"); + + const response = await GET(callbackRequest(`code=pkce-code&next=${encodeURIComponent(next)}`)); + + expect(response.headers.get("location")).toBe("https://clinical.example/"); + expect(exchangeCodeForSession).toHaveBeenCalledWith("pkce-code"); + }); + + it("returns provider errors without attempting a code exchange", async () => { + const createSupabaseServerClient = vi.fn(); + vi.doMock("@/lib/supabase/server", () => ({ createSupabaseServerClient })); + const { GET } = await import("../src/app/auth/callback/route"); + + const response = await GET(callbackRequest("error_description=Link+expired")); + + expect(response.headers.get("location")).toBe("https://clinical.example/?auth_error=Link%20expired"); + expect(createSupabaseServerClient).not.toHaveBeenCalled(); + }); + + it("falls back to the provider error code when no description is supplied", async () => { + const createSupabaseServerClient = vi.fn(); + vi.doMock("@/lib/supabase/server", () => ({ createSupabaseServerClient })); + const { GET } = await import("../src/app/auth/callback/route"); + + const response = await GET(callbackRequest("error=access_denied")); + + expect(response.headers.get("location")).toBe("https://clinical.example/?auth_error=access_denied"); + expect(createSupabaseServerClient).not.toHaveBeenCalled(); + }); + + it("reports missing codes and unconfigured auth", async () => { + const createSupabaseServerClient = vi.fn(async () => null); + vi.doMock("@/lib/supabase/server", () => ({ createSupabaseServerClient })); + const { GET } = await import("../src/app/auth/callback/route"); + + const missingCodeResponse = await GET(callbackRequest("next=%2Fdocuments")); + const unconfiguredResponse = await GET(callbackRequest("code=pkce-code")); + + expect(missingCodeResponse.headers.get("location")).toBe("https://clinical.example/?auth_error=missing_auth_code"); + expect(unconfiguredResponse.headers.get("location")).toBe("https://clinical.example/?auth_error=auth_unconfigured"); + expect(createSupabaseServerClient).toHaveBeenCalledTimes(1); + }); + + it("surfaces exchange failures without redirecting into the app", async () => { + const exchangeCodeForSession = vi.fn(async () => ({ error: { message: "Code verifier mismatch" } })); + vi.doMock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: vi.fn(async () => ({ auth: { exchangeCodeForSession } })), + })); + const { GET } = await import("../src/app/auth/callback/route"); + + const response = await GET(callbackRequest("code=bad-code&next=%2Fdocuments")); + + expect(response.headers.get("location")).toBe("https://clinical.example/?auth_error=Code%20verifier%20mismatch"); + }); +}); diff --git a/tests/clinical-dashboard-search-utils.test.ts b/tests/clinical-dashboard-search-utils.test.ts index 7f15f14ef..3b3632738 100644 --- a/tests/clinical-dashboard-search-utils.test.ts +++ b/tests/clinical-dashboard-search-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { answerPayloadIsUsable, classifyAnswerError, + isAnswerPayload, isRetryableError, keywordQueryFromNaturalLanguage, makeSearchError, @@ -52,6 +53,14 @@ describe("clinical dashboard search utilities", () => { ).toBe(true); }); + it("accepts only structurally valid answer payloads at the stream boundary", () => { + expect(isAnswerPayload(answer())).toBe(true); + expect(isAnswerPayload({ ...answer(), confidence: "certain" })).toBe(false); + expect(isAnswerPayload({ ...answer(), citations: null })).toBe(false); + expect(isAnswerPayload({ ...answer(), sources: "not-an-array" })).toBe(false); + expect(isAnswerPayload({ ...answer(), demoMode: "true" })).toBe(false); + }); + it("classifies retryable search errors", () => { expect(isRetryableError(makeSearchError("Service is currently unavailable.", 503, true))).toBe(true); expect(isRetryableError(makeSearchError("Search request was not authorized by the server.", 401))).toBe(false); diff --git a/tests/document-mutation-routes.test.ts b/tests/document-mutation-routes.test.ts new file mode 100644 index 000000000..039154c5f --- /dev/null +++ b/tests/document-mutation-routes.test.ts @@ -0,0 +1,313 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "11111111-1111-4111-8111-111111111111"; +const missingDocumentId = "22222222-2222-4222-8222-222222222222"; +const factId = "33333333-3333-4333-8333-333333333333"; +const imageId = "44444444-4444-4444-8444-444444444444"; +const committedGeneration = "55555555-5555-4555-8555-555555555555"; +const replacementGeneration = "66666666-6666-4666-8666-666666666666"; + +type QueryError = { message: string }; +type QueryResult = { data: unknown; error: QueryError | null }; +type QueryOperation = "select" | "update" | "upsert" | "delete"; +type QueryCall = { + table: string; + operation: QueryOperation; + selected?: string; + payload?: unknown; + options?: unknown; + filters: Array<{ column: string; value: unknown }>; + inFilters: Array<{ column: string; values: unknown[] }>; +}; +type QueryResolver = (call: QueryCall) => QueryResult; + +class QueryBuilder implements PromiseLike { + constructor( + private readonly call: QueryCall, + private readonly resolveCall: QueryResolver, + ) {} + + select(selected?: string) { + this.call.selected = selected; + return this; + } + + update(payload: unknown) { + this.call.operation = "update"; + this.call.payload = payload; + return this; + } + + upsert(payload: unknown, options?: unknown) { + this.call.operation = "upsert"; + this.call.payload = payload; + this.call.options = options; + return this; + } + + delete() { + this.call.operation = "delete"; + return this; + } + + eq(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + in(column: string, values: unknown[]) { + this.call.inFilters.push({ column, values }); + return this; + } + + maybeSingle() { + return this.resolve(); + } + + single() { + return this.resolve(); + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.resolve().then(onfulfilled, onrejected); + } + + private resolve() { + return Promise.resolve(this.resolveCall(this.call)); + } +} + +function createSupabaseMock(resolveCall: QueryResolver) { + const calls: QueryCall[] = []; + return { + calls, + client: { + from: vi.fn((table: string) => { + const call: QueryCall = { + table, + operation: "select", + filters: [], + inFilters: [], + }; + calls.push(call); + return new QueryBuilder(call, resolveCall); + }), + }, + }; +} + +function mockRouteRuntime(client: ReturnType["client"]) { + const invalidateRagCachesForOwner = vi.fn(); + class AuthenticationError extends Error {} + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError, + requireAuthenticatedUser: vi.fn(async () => ({ id: ownerId })), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + })); + vi.doMock("@/lib/rag", () => ({ invalidateRagCachesForOwner })); + return { invalidateRagCachesForOwner }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("/api/documents/bulk", () => { + it("applies owner-scoped metadata, title, and label edits", async () => { + const supabase = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select") { + return { + data: [{ id: documentId, title: "Old title", metadata: { retained: true } }], + error: null, + }; + } + return { data: null, error: null }; + }); + const { invalidateRagCachesForOwner } = mockRouteRuntime(supabase.client); + const { POST } = await import("../src/app/api/documents/bulk/route"); + + const response = await POST( + new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + documentIds: [documentId, missingDocumentId], + metadata: { sourceStatus: "review_due", publisher: "WA Health" }, + titleEdit: { prefix: "Updated: ", find: "Old", replace: "New" }, + labels: { + add: [{ label: "Cardiology", label_type: "topic", confidence: 0.9 }], + remove: [{ label: "Legacy", label_type: "topic" }], + }, + }), + }), + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + ok: true, + updatedCount: 1, + missingDocumentIds: [missingDocumentId], + results: [{ documentId, updated: true }], + }); + + const labelInsert = supabase.calls.find((call) => call.table === "document_labels" && call.operation === "upsert"); + expect(labelInsert?.payload).toEqual([ + expect.objectContaining({ + owner_id: ownerId, + document_id: documentId, + label: "cardiology", + label_type: "topic", + source: "manual", + }), + ]); + + const labelDelete = supabase.calls.find((call) => call.table === "document_labels" && call.operation === "delete"); + expect(labelDelete?.filters).toEqual( + expect.arrayContaining([ + { column: "owner_id", value: ownerId }, + { column: "label", value: "legacy" }, + { column: "label_type", value: "topic" }, + ]), + ); + expect(labelDelete?.inFilters).toContainEqual({ column: "document_id", values: [documentId] }); + + const documentUpdate = supabase.calls.find((call) => call.table === "documents" && call.operation === "update"); + expect(documentUpdate?.payload).toMatchObject({ + title: "Updated: New title", + metadata: { + retained: true, + document_status: "review_due", + publisher: "WA Health", + bulk_metadata_updated_by: ownerId, + }, + }); + expect(documentUpdate?.filters).toEqual( + expect.arrayContaining([ + { column: "id", value: documentId }, + { column: "owner_id", value: ownerId }, + ]), + ); + expect(invalidateRagCachesForOwner).toHaveBeenCalledWith(ownerId); + }); +}); + +describe("/api/documents/[id]/table-facts", () => { + it("updates a committed table fact and its linked source image", async () => { + const supabase = createSupabaseMock((call) => { + if (call.table === "documents") { + return { data: { id: documentId, metadata: { index_generation_id: committedGeneration } }, error: null }; + } + if (call.table === "document_table_facts" && call.operation === "select") { + return { + data: { + id: factId, + document_id: documentId, + owner_id: ownerId, + source_image_id: imageId, + metadata: { index_generation_id: committedGeneration }, + }, + error: null, + }; + } + if (call.table === "document_table_facts" && call.operation === "update") { + return { data: { id: factId, metadata: call.payload }, error: null }; + } + if (call.table === "document_images" && call.operation === "select") { + return { data: { id: imageId, metadata: { index_generation_id: committedGeneration } }, error: null }; + } + return { data: null, error: null }; + }); + const { invalidateRagCachesForOwner } = mockRouteRuntime(supabase.client); + const { PATCH } = await import("../src/app/api/documents/[id]/table-facts/route"); + + const response = await PATCH( + new Request(`http://localhost/api/documents/${documentId}/table-facts`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + factId, + reviewClass: "clinical_useful", + notes: "Verified against the source table", + confidence: 0.95, + }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + + expect(response.status).toBe(200); + const factUpdate = supabase.calls.find( + (call) => call.table === "document_table_facts" && call.operation === "update", + ); + expect(factUpdate?.payload).toMatchObject({ + metadata: expect.objectContaining({ + index_generation_id: committedGeneration, + review_class: "clinical_useful", + reviewed_by: ownerId, + review_confidence: 0.95, + }), + }); + const imageUpdate = supabase.calls.find((call) => call.table === "document_images" && call.operation === "update"); + expect(imageUpdate?.payload).toMatchObject({ + searchable: true, + metadata: expect.objectContaining({ + index_generation_id: committedGeneration, + review_class: "clinical_useful", + reviewed_by: ownerId, + }), + }); + expect(invalidateRagCachesForOwner).toHaveBeenCalledWith(ownerId); + }); + + it("rejects a stale linked image before mutating the committed fact", async () => { + const supabase = createSupabaseMock((call) => { + if (call.table === "documents") { + return { data: { id: documentId, metadata: { index_generation_id: committedGeneration } }, error: null }; + } + if (call.table === "document_table_facts" && call.operation === "select") { + return { + data: { + id: factId, + document_id: documentId, + owner_id: ownerId, + source_image_id: imageId, + metadata: { index_generation_id: committedGeneration }, + }, + error: null, + }; + } + if (call.table === "document_table_facts" && call.operation === "update") { + return { data: { id: factId, metadata: call.payload }, error: null }; + } + if (call.table === "document_images" && call.operation === "select") { + return { data: { id: imageId, metadata: { index_generation_id: replacementGeneration } }, error: null }; + } + return { data: null, error: null }; + }); + const { invalidateRagCachesForOwner } = mockRouteRuntime(supabase.client); + const { PATCH } = await import("../src/app/api/documents/[id]/table-facts/route"); + + const response = await PATCH( + new Request(`http://localhost/api/documents/${documentId}/table-facts`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ factId, reviewClass: "bad_extraction" }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + + expect(response.status).toBe(404); + expect(supabase.calls.some((call) => call.table === "document_table_facts" && call.operation === "update")).toBe( + false, + ); + expect(supabase.calls.some((call) => call.table === "document_images" && call.operation === "update")).toBe(false); + expect(invalidateRagCachesForOwner).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/extracted-document-validation.test.ts b/tests/extracted-document-validation.test.ts new file mode 100644 index 000000000..72d29e052 --- /dev/null +++ b/tests/extracted-document-validation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseExtractedDocumentPayload } from "../src/lib/extractors/document"; + +describe("Python extracted-document payload validation", () => { + it("accepts the extractor contract", () => { + expect( + parseExtractedDocumentPayload( + JSON.stringify({ + pages: [{ pageNumber: 1, text: "Clinical source text", ocrUsed: true }], + images: [ + { + pageNumber: 1, + path: "C:/tmp/page-1-table.png", + mimeType: "image/png", + bbox: [10, 20, 300, 180], + width: 290, + height: 160, + sourceKind: "table_crop", + metadata: { table_title: "Monitoring thresholds" }, + }, + ], + warnings: [], + }), + ), + ).toMatchObject({ + pages: [{ pageNumber: 1, text: "Clinical source text", ocrUsed: true }], + images: [{ pageNumber: 1, sourceKind: "table_crop" }], + }); + }); + + it.each([ + { + name: "non-string page text", + payload: { pages: [{ pageNumber: 1, text: 42 }], images: [] }, + }, + { + name: "an incomplete image bounding box", + payload: { + pages: [], + images: [{ pageNumber: 1, path: "table.png", mimeType: "image/png", bbox: [0, 0, 20] }], + }, + }, + { + name: "an image without a path", + payload: { pages: [], images: [{ pageNumber: 1, mimeType: "image/png" }] }, + }, + ])("rejects $name", ({ payload }) => { + expect(() => parseExtractedDocumentPayload(JSON.stringify(payload))).toThrow(); + }); +}); diff --git a/tests/supabase-server.test.ts b/tests/supabase-server.test.ts new file mode 100644 index 000000000..482defd1e --- /dev/null +++ b/tests/supabase-server.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("Supabase server client", () => { + it("stays disabled when public Supabase configuration is incomplete", async () => { + const createServerClient = vi.fn(); + const cookies = vi.fn(); + vi.doMock("server-only", () => ({})); + vi.doMock("@supabase/ssr", () => ({ createServerClient })); + vi.doMock("next/headers", () => ({ cookies })); + vi.doMock("@/lib/env", () => ({ + env: { NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co" }, + })); + const { createSupabaseServerClient, publicSupabaseConfig } = await import("../src/lib/supabase/server"); + + expect(publicSupabaseConfig()).toBeNull(); + await expect(createSupabaseServerClient()).resolves.toBeNull(); + expect(cookies).not.toHaveBeenCalled(); + expect(createServerClient).not.toHaveBeenCalled(); + }); + + it("connects the Next.js cookie store to the SSR client", async () => { + const cookieRows = [{ name: "sb-session", value: "session-value" }]; + const cookieStore = { + getAll: vi.fn(() => cookieRows), + set: vi.fn(), + }; + let cookieAdapter: + | { + getAll(): typeof cookieRows; + setAll(rows: Array<{ name: string; value: string; options: Record }>): void; + } + | undefined; + const createdClient = { kind: "server-client" }; + const createServerClient = vi.fn( + (_url: string, _key: string, options: { cookies: NonNullable }) => { + cookieAdapter = options.cookies; + return createdClient; + }, + ); + vi.doMock("server-only", () => ({})); + vi.doMock("@supabase/ssr", () => ({ createServerClient })); + vi.doMock("next/headers", () => ({ cookies: vi.fn(async () => cookieStore) })); + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + })); + const { createSupabaseServerClient, publicSupabaseConfig } = await import("../src/lib/supabase/server"); + + expect(publicSupabaseConfig()).toEqual({ + url: "https://project.supabase.co", + key: "sb_publishable_test", + }); + await expect(createSupabaseServerClient()).resolves.toBe(createdClient); + expect(createServerClient).toHaveBeenCalledWith( + "https://project.supabase.co", + "sb_publishable_test", + expect.objectContaining({ cookies: expect.any(Object) }), + ); + expect(cookieAdapter?.getAll()).toEqual(cookieRows); + + const options = { httpOnly: true }; + cookieAdapter?.setAll([{ name: "sb-refresh", value: "new-value", options }]); + expect(cookieStore.set).toHaveBeenCalledWith("sb-refresh", "new-value", options); + }); + + it("tolerates cookie writes from read-only Server Component contexts", async () => { + const cookieStore = { + getAll: vi.fn(() => []), + set: vi.fn(() => { + throw new Error("Cookies can only be modified in a Server Action or Route Handler"); + }), + }; + let setAll: ((rows: Array<{ name: string; value: string; options: Record }>) => void) | undefined; + vi.doMock("server-only", () => ({})); + vi.doMock("@supabase/ssr", () => ({ + createServerClient: vi.fn( + (_url: string, _key: string, options: { cookies: { setAll: NonNullable } }) => { + setAll = options.cookies.setAll; + return { kind: "server-client" }; + }, + ), + })); + vi.doMock("next/headers", () => ({ cookies: vi.fn(async () => cookieStore) })); + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + })); + const { createSupabaseServerClient } = await import("../src/lib/supabase/server"); + + await createSupabaseServerClient(); + + expect(() => setAll?.([{ name: "sb-refresh", value: "new-value", options: {} }])).not.toThrow(); + expect(cookieStore.set).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0ed6c5f8e..74d0b2be3 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1494,7 +1494,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`, { waitUntil: "domcontentloaded", }); - await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); const answerSurface = page.locator('[data-dashboard-stage="answer-surface"]'); const strip = answerSurface.getByTestId("cross-mode-links"); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index e40f82ec7..a1560b6c0 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -122,6 +122,24 @@ test.describe("universal search smart affordances", () => { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { await route.fulfill({ json: smartPayload }); }); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + const answer = { + answer: "Synthetic answer for the universal-search navigation check.", + grounded: false, + confidence: "unsupported", + citations: [], + sources: [], + demoMode: true, + }; + if (new URL(route.request().url()).pathname.endsWith("/stream")) { + await route.fulfill({ + body: `event: final\ndata: ${JSON.stringify(answer)}\n\n`, + contentType: "text/event-stream; charset=utf-8", + }); + return; + } + await route.fulfill({ json: answer }); + }); } test("shows the interpretation banner, a Best match, and an Ask-this bridge", async ({ page }) => { @@ -141,7 +159,12 @@ test.describe("universal search smart affordances", () => { const ask = page.getByRole("option", { name: /Ask this question/ }); await expect(ask).toBeVisible(); + const answerRequest = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/answer/stream" && request.method() === "POST", + ); await ask.click(); + expect((await answerRequest).postDataJSON()).toMatchObject({ query: "acamprosat" }); + await expect(page.getByRole("main").getByRole("heading", { name: "Answer", exact: true })).toBeVisible(); await expect(page).toHaveURL(/mode=answer/); }); }); diff --git a/vitest.config.mts b/vitest.config.mts index f1cc42d59..62d8f5f80 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,6 +1,9 @@ const config = { test: { - testTimeout: 15000, + // Route and RAG tests cold-import large Next.js module graphs inside the test + // body. Give those transforms headroom on slower worktree filesystems while + // retaining a finite timeout that still catches genuine hangs. + testTimeout: 30_000, coverage: { provider: "v8", reporter: ["text", "lcov"],