diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index cc3907d73..a7314e7fd 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -24,7 +24,6 @@ jobs: # Runs OSS-engine only — no Semgrep account or token required. semgrep: name: Semgrep - continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 15 container: @@ -36,6 +35,7 @@ jobs: persist-credentials: false - name: Semgrep scan + continue-on-error: true # --error fails the job on any ERROR-severity finding; --oss-only keeps # it token-free; --metrics off sends no telemetry. Named registry rules # remain advisory because they can change independently of this repository. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 15dd0df13..6545f04c8 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -50,3 +50,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | | 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | | 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. | +| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. | diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index fc32a4fdd..402e3f60e 100644 --- a/docs/privacy-impact-assessment.md +++ b/docs/privacy-impact-assessment.md @@ -109,9 +109,9 @@ Clinician browser │ • source_chunk_ids= real chunk UUIDs ← owner's own data │ • metadata.query_hash = HMAC/SHA-256 (query-privacy.ts:51) │ - └──►(E) RESPONSE CACHE (Supabase rag_response_cache, owner-scoped) + └──►(E) RESPONSE CACHE (Supabase rag_response_cache, authenticated owner-scoped) payload = full answer, TTL ~5 min (RAG_ANSWER_CACHE_TTL_MS) - keyed by owner_id predicate (rag.ts:1667) — no cross-tenant serve + disabled for anonymous answers; authenticated rows are keyed by owner_id ▼ Clinician browser ← answer + citations ``` @@ -323,8 +323,8 @@ material shortfall is **governance/contractual** — the APP 8 cross-border DPA/ collection notices are now live on `main`; see PIA-1 progress) — plus the **hardening** item still open (conditional HMAC). Un-redacted answer retention (PIA-3) is now closed for the durable log; the `rag_response_cache` still retains answers until a same-key overwrite (read-TTL only, no purge cron — -see §8). None of these are cross-tenant data-leak bugs — the tenancy review found **zero** confirmed -cross-tenant leaks — they are compliance-posture and PHI-minimisation gaps. +see §8). Anonymous answer caching is disabled, and the tenancy review found **zero** confirmed +cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisation gaps. --- diff --git a/docs/staging-setup.md b/docs/staging-setup.md index 27be48374..26bf9ea7d 100644 --- a/docs/staging-setup.md +++ b/docs/staging-setup.md @@ -25,16 +25,14 @@ those vars are unset. 2. **Apply the schema.** From a checkout linked to the staging project: - ````bash + ```bash supabase link --project-ref supabase db push # applies supabase/migrations/* → matches schema.sql - ```dotenv + ``` Then confirm health: `npm run check:indexing` (runs `search_schema_health()` over the hybrid RPCs) should report ok. - ```` - 3. **Seed a small corpus (~50 docs).** Use synthetic/public content only — do **not** copy clinical production documents into staging. diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index d9c0947d4..5d3de4631 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,5 +1,6 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; +import { yamlBlock } from "./yaml-contract.mjs"; const workflowDir = path.join(process.cwd(), ".github", "workflows"); @@ -35,6 +36,7 @@ const usesPattern = /^\s*uses:\s*([^@\s]+)@v(\d+)\s*(?:#.*)?$/; const runsOnLatestPattern = /^\s*runs-on:\s*ubuntu-latest\s*(?:#.*)?$/; const failures = []; const expectedSupabaseCliVersion = "2.108.0"; +const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); for (const fileName of readdirSync(workflowDir) .filter((name) => /\.ya?ml$/i.test(name)) @@ -67,18 +69,26 @@ for (const fileName of readdirSync(workflowDir) const ciWorkflowPath = path.join(workflowDir, "ci.yml"); const ciWorkflow = readFileSync(ciWorkflowPath, "utf8"); -const requiredCiFragments = [ - `SUPABASE_CLI_VERSION: ${expectedSupabaseCliVersion}`, - "version: ${{ env.SUPABASE_CLI_VERSION }}", - "id: supabase-docker-cache", - "supabase-docker-${{ runner.os }}-cli-${{ env.SUPABASE_CLI_VERSION }}-", - "if: success() && steps.supabase-docker-cache.outputs.cache-hit != 'true'", -]; - -for (const fragment of requiredCiFragments) { - if (!ciWorkflow.includes(fragment)) { - failures.push(`ci.yml: missing required pinned Supabase/cache contract: ${fragment}`); - } +const migrationJob = yamlBlock(ciWorkflow, "db-reset-verify:", 2); +const setupSupabaseStep = yamlBlock(migrationJob, "- name: Setup Supabase CLI", 6); +const restoreSupabaseStep = yamlBlock(migrationJob, "- name: Restore Supabase Docker image cache", 6); +const saveSupabaseStep = yamlBlock(migrationJob, "- name: Save Supabase Docker images", 6); +if (!new RegExp(`^ SUPABASE_CLI_VERSION: ${expectedSupabaseCliVersionPattern}$`, "m").test(ciWorkflow)) { + failures.push(`ci.yml: global SUPABASE_CLI_VERSION must remain pinned to ${expectedSupabaseCliVersion}.`); +} +if (!/^ version: \$\{\{ env\.SUPABASE_CLI_VERSION \}\}$/m.test(setupSupabaseStep)) { + failures.push("ci.yml: db-reset-verify Setup Supabase CLI must use the pinned env version."); +} +if ( + !/^ id: supabase-docker-cache$/m.test(restoreSupabaseStep) || + !restoreSupabaseStep.includes("supabase-docker-${{ runner.os }}-cli-${{ env.SUPABASE_CLI_VERSION }}-") +) { + failures.push("ci.yml: db-reset-verify cache step must own the pinned Supabase cache id/key."); +} +if ( + !/^ if: success\(\) && steps\.supabase-docker-cache\.outputs\.cache-hit != 'true'$/m.test(saveSupabaseStep) +) { + failures.push("ci.yml: db-reset-verify save step must be gated by its own cache-hit output."); } if (/\bversion:\s*latest\b/.test(ciWorkflow)) { @@ -87,11 +97,16 @@ if (/\bversion:\s*latest\b/.test(ciWorkflow)) { const sastWorkflowPath = path.join(workflowDir, "sast.yml"); const sastWorkflow = readFileSync(sastWorkflowPath, "utf8"); -if (!/^\s{4}continue-on-error:\s*true\s*$/m.test(sastWorkflow)) { - failures.push("sast.yml: Semgrep must remain advisory while it depends on mutable registry rules."); +const semgrepJob = yamlBlock(sastWorkflow, "semgrep:", 2); +const semgrepScanStep = yamlBlock(semgrepJob, "- name: Semgrep scan", 6); +if (/^ continue-on-error:\s*true\s*$/m.test(semgrepJob)) { + failures.push("sast.yml: only the Semgrep scan step may be advisory; job setup failures must block."); +} +if (!/^ continue-on-error:\s*true\s*$/m.test(semgrepScanStep)) { + failures.push("sast.yml: the Semgrep scan step must remain advisory while registry rules are mutable."); } -if (!/src worker scripts supabase\/functions/.test(sastWorkflow)) { - failures.push("sast.yml: Semgrep must scan Supabase Edge Function source."); +if (!/^ src worker scripts supabase\/functions\s*$/m.test(semgrepScanStep)) { + failures.push("sast.yml: the Semgrep scan command must target src, worker, scripts, and supabase/functions."); } if (failures.length > 0) { diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index 4d55a50c2..2c9efddb4 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -68,19 +68,23 @@ async function main() { docker(["run", "-d", "--name", CONTAINER, "-e", `POSTGRES_PASSWORD=${scratchPassword}`, "-p", `${port}:5432`, image]); try { - // Wait for Postgres readiness (image init runs its own restarts; give it time). - let ready = false; + // The Supabase image briefly accepts connections before its init migrations + // deliberately restart Postgres. Require sustained readiness so replay does + // not race that shutdown window. + let consecutiveReadyChecks = 0; for (let attempt = 0; attempt < 60; attempt += 1) { try { docker(["exec", CONTAINER, "pg_isready", "-U", "postgres", "-q"]); - ready = true; - break; + consecutiveReadyChecks += 1; + if (consecutiveReadyChecks >= 5) break; } catch { - await new Promise((resolve) => setTimeout(resolve, 2000)); + consecutiveReadyChecks = 0; } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + if (consecutiveReadyChecks < 5) { + throw new Error("scratch Postgres did not remain ready after initialization"); } - if (!ready) throw new Error("scratch Postgres did not become ready in time"); - await new Promise((resolve) => setTimeout(resolve, 3000)); const psql = (user: string, sql: string) => docker( diff --git a/scripts/resolve-tsx-cli.mjs b/scripts/resolve-tsx-cli.mjs new file mode 100644 index 000000000..b7e77e850 --- /dev/null +++ b/scripts/resolve-tsx-cli.mjs @@ -0,0 +1,55 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +function addAncestors(candidates, start) { + let current = resolve(start); + while (true) { + candidates.add(current); + const parent = dirname(current); + if (parent === current) return; + current = parent; + } +} + +function worktreeRoots(projectRoot) { + const roots = new Set(); + const dotGit = join(projectRoot, ".git"); + if (!existsSync(dotGit)) return roots; + try { + const pointer = readFileSync(dotGit, "utf8") + .trim() + .replace(/^gitdir:\s*/i, ""); + const gitDir = isAbsolute(pointer) ? pointer : resolve(projectRoot, pointer); + const commonGitDir = resolve(gitDir, "..", ".."); + roots.add(dirname(commonGitDir)); + const worktreesDir = join(commonGitDir, "worktrees"); + if (existsSync(worktreesDir)) { + for (const name of readdirSync(worktreesDir)) { + const gitdirFile = join(worktreesDir, name, "gitdir"); + if (!existsSync(gitdirFile)) continue; + const worktreeGitFile = readFileSync(gitdirFile, "utf8").trim(); + roots.add(dirname(worktreeGitFile)); + } + } + } catch { + // A normal checkout has a .git directory, not a pointer file. Ancestor + // lookup below already covers its local node_modules. + } + return roots; +} + +export function resolveTsxCli(projectRoot, moduleResolve = (specifier) => import.meta.resolve(specifier)) { + try { + return fileURLToPath(moduleResolve("tsx/cli")); + } catch { + const roots = new Set(); + addAncestors(roots, projectRoot); + for (const root of worktreeRoots(projectRoot)) addAncestors(roots, root); + for (const root of roots) { + const candidate = join(root, "node_modules", "tsx", "dist", "cli.mjs"); + if (existsSync(candidate)) return candidate; + } + throw new Error(`Unable to resolve tsx/cli from ${projectRoot} or its Git worktrees.`); + } +} diff --git a/scripts/run-tsx.mjs b/scripts/run-tsx.mjs index 9357ef3fe..2471dc99c 100644 --- a/scripts/run-tsx.mjs +++ b/scripts/run-tsx.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveTsxCli } from "./resolve-tsx-cli.mjs"; const args = process.argv.slice(2); if (args.length === 0) { @@ -8,7 +10,8 @@ if (args.length === 0) { process.exit(1); } -const tsxCli = fileURLToPath(import.meta.resolve("tsx/cli")); +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const tsxCli = resolveTsxCli(projectRoot); const hook = new URL("./enable-server-only-stub.mjs", import.meta.url).href; const child = spawn(process.execPath, [tsxCli, "--import", hook, ...args], { cwd: process.cwd(), diff --git a/scripts/yaml-contract.mjs b/scripts/yaml-contract.mjs new file mode 100644 index 000000000..f34914b78 --- /dev/null +++ b/scripts/yaml-contract.mjs @@ -0,0 +1,18 @@ +export function yamlBlock(source, header, indent) { + const lines = source.split(/\r?\n/); + const prefix = `${" ".repeat(indent)}${header}`; + const start = lines.findIndex((line) => line === prefix); + if (start < 0) return ""; + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const leading = line.length - line.trimStart().length; + if (leading <= indent) { + end = index; + break; + } + } + return lines.slice(start, end).join("\n"); +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 7190ebe88..f727ae19d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -942,7 +942,7 @@ export function ClinicalDashboard({ try { const response = await fetch(input, { ...init, signal: controller.signal }); if (!isAuthEpochCurrent(authRequest.epoch)) throw new DOMException("Stale authentication epoch", "AbortError"); - return response; + return { response, requestEpoch: authRequest.epoch }; } finally { authRequest.release(); } @@ -1461,7 +1461,7 @@ export function ClinicalDashboard({ setLoadingMoreDocuments(true); try { const protectedHeaders = clientDemoMode ? undefined : authorizationHeader; - const response = await authBoundFetch( + const { response, requestEpoch } = await authBoundFetch( `/api/documents?limit=${documentPageSize}&offset=${documentsPagination.nextOffset}`, { headers: protectedHeaders }, ); @@ -1470,16 +1470,20 @@ export function ClinicalDashboard({ return; } if (!response.ok) { + if (!isAuthEpochCurrent(requestEpoch)) return; setApiUnavailable(true); return; } const payload = await response.json(); + if (!isAuthEpochCurrent(requestEpoch)) return; const nextDocuments = (payload.documents ?? []) as ClinicalDocument[]; setDocuments((current) => { const seen = new Set(current.map((document) => document.id)); return [...current, ...nextDocuments.filter((document) => !seen.has(document.id))]; }); setDocumentsPagination(payload.pagination ?? null); + } catch (error) { + if (!isAbortError(error)) setApiUnavailable(true); } finally { setLoadingMoreDocuments(false); } @@ -1489,6 +1493,7 @@ export function ClinicalDashboard({ canUsePrivateApis, clientDemoMode, documentsPagination, + isAuthEpochCurrent, loadingMoreDocuments, markSessionExpired, ]); @@ -1497,7 +1502,7 @@ export function ClinicalDashboard({ async (jobId: string) => { setIndexingActionId(jobId); try { - const response = await authBoundFetch(`/api/ingestion/jobs/${jobId}/retry`, { + const { response, requestEpoch } = await authBoundFetch(`/api/ingestion/jobs/${jobId}/retry`, { method: "POST", headers: authorizationHeader, }); @@ -1506,6 +1511,7 @@ export function ClinicalDashboard({ return; } const payload = await response.json().catch(() => ({})); + if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) { throw new Error(typeof payload.error === "string" ? payload.error : "Job retry could not be started."); } @@ -1524,14 +1530,14 @@ export function ClinicalDashboard({ setIndexingActionId(null); } }, - [authBoundFetch, authorizationHeader, markSessionExpired, refresh], + [authBoundFetch, authorizationHeader, isAuthEpochCurrent, markSessionExpired, refresh], ); const reindexDocument = useCallback( async (documentId: string, mode: "full" | "enrichment" = "full") => { setIndexingActionId(documentId); try { - const response = await authBoundFetch(`/api/documents/${documentId}/reindex`, { + const { response, requestEpoch } = await authBoundFetch(`/api/documents/${documentId}/reindex`, { method: "POST", headers: { ...authorizationHeader, @@ -1544,6 +1550,7 @@ export function ClinicalDashboard({ return; } const payload = await response.json().catch(() => ({})); + if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) { throw new Error( typeof payload.error === "string" @@ -1568,7 +1575,7 @@ export function ClinicalDashboard({ setIndexingActionId(null); } }, - [authBoundFetch, authorizationHeader, markSessionExpired, refresh], + [authBoundFetch, authorizationHeader, isAuthEpochCurrent, markSessionExpired, refresh], ); const enrichDocument = useCallback( (documentId: string) => reindexDocument(documentId, "enrichment"), @@ -1637,7 +1644,7 @@ export function ClinicalDashboard({ async (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => { if (!canUsePrivateApis) return false; try { - const response = await authBoundFetch(`/api/documents/${documentId}/labels`, { + const { response, requestEpoch } = await authBoundFetch(`/api/documents/${documentId}/labels`, { method, headers: { "Content-Type": "application/json", @@ -1646,6 +1653,7 @@ export function ClinicalDashboard({ body: JSON.stringify(body), }); const payload = await response.json().catch(() => ({})); + if (!isAuthEpochCurrent(requestEpoch)) return false; if (response.status === 401) { markSessionExpired(); return false; @@ -1677,6 +1685,7 @@ export function ClinicalDashboard({ clientDemoMode, handleDocumentLabelPatched, handleDocumentLabelsUpdated, + isAuthEpochCurrent, markSessionExpired, ], ); @@ -2730,7 +2739,7 @@ export function ClinicalDashboard({ setBulkActionBusy(true); setBulkActionStatus(null); try { - const response = await authBoundFetch("/api/documents/bulk/reindex", { + const { response, requestEpoch } = await authBoundFetch("/api/documents/bulk/reindex", { method: "POST", headers: { "Content-Type": "application/json", @@ -2743,6 +2752,7 @@ export function ClinicalDashboard({ return; } const payload = await response.json().catch(() => ({})); + if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) throw new Error(payload.error || errorCopy.bulkReindexFailed); setBulkActionStatus( `${payload.results?.filter((result: { ok: boolean }) => result.ok).length ?? 0} selected documents updated.`, @@ -2766,7 +2776,7 @@ export function ClinicalDashboard({ setBulkActionBusy(true); setBulkActionStatus(null); try { - const response = await authBoundFetch("/api/documents/bulk", { + const { response, requestEpoch } = await authBoundFetch("/api/documents/bulk", { method: "POST", headers: { "Content-Type": "application/json", @@ -2779,6 +2789,7 @@ export function ClinicalDashboard({ return; } const payload = await response.json().catch(() => ({})); + if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) throw new Error(payload.error || errorCopy.bulkMetadataUpdateFailed); setBulkActionStatus(`${payload.updatedCount ?? 0} selected documents updated.`); await refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); @@ -2843,6 +2854,7 @@ export function ClinicalDashboard({ setSelectedDocumentIds([]); setScopeFilters({}); resetAnswerThread(); + dispatchAnswerLifecycle({ type: "reset" }); setAnswer(null); setSources([]); setDocumentMatches([]); diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 121e64c54..204ff6cf7 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -172,6 +172,19 @@ export type UploadOutcome = | { kind: "duplicate"; fileName: string; documentId: string; message: string } | { kind: "failed"; fileName: string; status: number; code: string; message: string }; +export function uploadBatchCompletion(outcomes: UploadOutcome[]) { + const queued = outcomes.filter((outcome) => outcome.kind === "queued"); + const duplicates = outcomes.filter((outcome) => outcome.kind === "duplicate"); + const failures = outcomes.filter((outcome) => outcome.kind === "failed"); + return { + queued, + duplicates, + failures, + shouldClearInput: queued.length + duplicates.length > 0, + shouldRefreshDocuments: queued.length > 0, + }; +} + type UploadResponsePayload = { error?: string; message?: string; @@ -354,9 +367,7 @@ export function UploadPanel({ } } - const queued = outcomes.filter((outcome) => outcome.kind === "queued"); - const duplicates = outcomes.filter((outcome) => outcome.kind === "duplicate"); - const failures = outcomes.filter((outcome) => outcome.kind === "failed"); + const { queued, duplicates, failures, shouldClearInput, shouldRefreshDocuments } = uploadBatchCompletion(outcomes); setUploadPercent(failures.length === 0 ? 100 : null); if (failures.length === 0) { const parts = [ @@ -364,14 +375,14 @@ export function UploadPanel({ duplicates.length ? `${duplicates.length} already existed; no indexing job was queued` : null, ].filter(Boolean); changeStatus(parts.join(". ") + "."); - if (input) input.value = ""; - if (queued.length) onUploaded(); } else { const successful = queued.length + duplicates.length; changeStatus( `Upload complete: ${successful} accepted; ${failures.length} failed. ${failures.map((outcome) => `${outcome.fileName}: ${outcome.message}`).join("; ")}`, ); } + if (input && shouldClearInput) input.value = ""; + if (shouldRefreshDocuments) onUploaded(); setUploading(false); setUploadPercent(null); } diff --git a/src/components/differentials/differentials-home-page.tsx b/src/components/differentials/differentials-home-page.tsx index 9318f2ac7..d42d2a685 100644 --- a/src/components/differentials/differentials-home-page.tsx +++ b/src/components/differentials/differentials-home-page.tsx @@ -72,6 +72,21 @@ export function DifferentialsHomePage({ query = "", autoRunSearch = false }: Dif } }, [autoRunSearch, trimmedQuery, runSearch]); + const navigateToSearch = useCallback( + (nextQuery: string) => { + router.push( + appModeHomeHref("differentials", { + query: nextQuery, + run: true, + focus: true, + queryMode: routedSearchContext.queryMode, + scopeFilters: routedSearchContext.scopeFilters, + }), + ); + }, + [router, routedSearchContext.queryMode, routedSearchContext.scopeFilters], + ); + return ( { - router.push( - appModeHomeHref("differentials", { - query: nextQuery, - run: true, - focus: true, - queryMode: routedSearchContext.queryMode, - scopeFilters: routedSearchContext.scopeFilters, - }), - ); - }} + onRunSearch={navigateToSearch} + onSuggestedSearch={navigateToSearch} /> ); 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 74e5bab1e..ba9c888b5 100644 --- a/src/components/tools-page-mockups/tools-page-mockup-page.tsx +++ b/src/components/tools-page-mockups/tools-page-mockup-page.tsx @@ -1103,7 +1103,14 @@ function SplitPaneMockup() { {filter.filtered.length > 0 ? (
{filter.filtered.map((tool) => ( - + setSelectedToolId(tool.id)} + /> ))}
) : ( diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index ca0c7d682..bf9c620c9 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -535,6 +535,9 @@ export function formatAnswerRenderCopyText(args: { ) : ["No policy-approved sources were attached."]; const warningLines = args.warnings.length ? args.warnings.map((warning) => `- ${warning}`) : ["- None"]; + const visualEvidenceLines = args.visualEvidence?.length + ? formatDisplayedVisualEvidenceForClipboard(args.visualEvidence) + : []; return [ "Clinical answer draft", @@ -551,9 +554,7 @@ export function formatAnswerRenderCopyText(args: { "", "Warnings", ...warningLines, - ...(args.visualEvidence?.length - ? ["", "Displayed table evidence", ...formatDisplayedVisualEvidenceForClipboard(args.visualEvidence)] - : []), + ...(visualEvidenceLines.length ? ["", "Displayed table evidence", ...visualEvidenceLines] : []), ] .join("\n") .trim(); diff --git a/src/lib/auth-request-lifecycle.ts b/src/lib/auth-request-lifecycle.ts index 2c811b3a3..c11f6a3a7 100644 --- a/src/lib/auth-request-lifecycle.ts +++ b/src/lib/auth-request-lifecycle.ts @@ -10,6 +10,10 @@ export type AuthRequestLifecycle = { invalidate: () => number; }; +export function authSessionFingerprint(status: string, userId?: string | null) { + return `${status}:${userId ?? "anonymous"}`; +} + /** * Keeps user-scoped browser work tied to the authentication state that started it. * Invalidating the lifecycle aborts every registered request before advancing the diff --git a/src/lib/observability/answer-slo.ts b/src/lib/observability/answer-slo.ts index ddfa52f63..ba32d6a80 100644 --- a/src/lib/observability/answer-slo.ts +++ b/src/lib/observability/answer-slo.ts @@ -28,6 +28,7 @@ type CountResult = { count: number | null; error: unknown }; // route passes it directly and tests pass a small fake. type SloCountBuilder = PromiseLike & { gt(column: string, value: string): SloCountBuilder; + is(column: string, value: null): SloCountBuilder; not(column: string, operator: string, value: null): SloCountBuilder; }; @@ -49,7 +50,15 @@ function rate(numerator: number, denominator: number) { */ export async function answerSloSnapshot(client: SloProbeClient, windowMinutes = 60): Promise { const sinceIso = new Date(Date.now() - windowMinutes * 60_000).toISOString(); - const base = () => client.from("rag_queries").select("*", { count: "exact", head: true }).gt("created_at", sinceIso); + const base = () => + client + .from("rag_queries") + .select("*", { count: "exact", head: true }) + .gt("created_at", sinceIso) + // Search observations share rag_queries but carry a dedicated event_type. + // Answer text is privacy-redacted to null by default, so it cannot be the + // discriminator without hiding normal production answers from the SLO. + .is("metadata->>event_type", null); const [total, hybrid, degraded] = await Promise.all([ base(), diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index df9b69faf..9a143eb6f 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -27,8 +27,14 @@ export function persistPrivateSearchScope( documentIds: validated, expiresAt: now + privateSearchScopeTtlMs, }; - storage.setItem(`${storagePrefix}${scopeRef}`, JSON.stringify(value)); - return scopeRef; + try { + storage.setItem(`${storagePrefix}${scopeRef}`, JSON.stringify(value)); + return scopeRef; + } catch { + // Browser privacy settings, quota exhaustion, or disabled storage should + // remove only URL restoration, not prevent the scoped request itself. + return null; + } } export function restorePrivateSearchScope( diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index fba67bd96..c2c1a804e 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -61,11 +61,16 @@ export function cloneAnswer(answer: RagAnswer) { return structuredClone(answer); } +/** Anonymous callers share no stable identity, so their PHI-bearing answers must never be cached or coalesced. */ +export function answerCacheAllowedForOwner(ownerId?: string | null) { + return Boolean(ownerId); +} + export async function getCachedAnswer( args: Pick, startedAt: number, ): Promise { - if (args.skipCache) return null; + if (!answerCacheAllowedForOwner(args.ownerId) || args.skipCache) return null; if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return null; const key = scopedAnswerCacheKey(args); @@ -95,7 +100,7 @@ export async function setCachedAnswer( answer: RagAnswer, options?: { indexingVersionAtRetrievalStart?: string | null }, ): Promise { - if (args.skipCache) return; + if (!answerCacheAllowedForOwner(args.ownerId) || args.skipCache) return; if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return; if (options?.indexingVersionAtRetrievalStart) { @@ -446,7 +451,7 @@ export async function getSharedCachedAnswer( >, startedAt: number, ) { - if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; + if (!answerCacheAllowedForOwner(args.ownerId) || args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; try { const indexingVersion = await cacheIndexingVersion(args); const { data, error } = await sharedCacheSelector( @@ -534,7 +539,7 @@ function setSharedCachedAnswer( >, answer: RagAnswer, ) { - if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; + if (!answerCacheAllowedForOwner(args.ownerId) || args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; void replaceSharedCacheRow("answer", args, { answer: cloneAnswer(answer) }, env.RAG_ANSWER_CACHE_TTL_MS); } diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 59f52022c..0964ada37 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -65,6 +65,7 @@ export { textCandidateBudgetForQueryClass, } from "@/lib/rag-retrieval-variants"; import { + answerCacheAllowedForOwner, answerInflight, cacheIndexingVersion, cloneAnswer, @@ -3661,11 +3662,15 @@ export async function answerQuestion(query: string, documentId?: string) { /** Answer question with scope. */ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs): Promise { const startedAt = Date.now(); - const coalescingEnabled = !args.skipCache && env.RAG_ANSWER_CACHE_TTL_MS > 0 && env.RAG_ANSWER_CACHE_SIZE > 0; + const coalescingEnabled = + answerCacheAllowedForOwner(args.ownerId) && + !args.skipCache && + env.RAG_ANSWER_CACHE_TTL_MS > 0 && + env.RAG_ANSWER_CACHE_SIZE > 0; const inflightKey = coalescingEnabled ? scopedAnswerCacheKey(args) : null; - const existing = inflightKey ? answerInflight.get(inflightKey) : undefined; + let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; - if (existing) { + while (existing) { await args.onProgress?.({ stage: "cached", message: "Waiting for an identical cited answer request already in progress.", @@ -3685,7 +3690,15 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) // The in-flight request we coalesced onto failed — most often because the ORIGINATING // caller aborted mid-flight (its AbortSignal is not ours) or its search phase threw. Do // not propagate another caller's failure to this still-connected request: fall through to - // an independent, uncoalesced run so this request is decided only by its own inputs. + // one replacement run. Recheck the map first: another still-connected + // waiter may already have installed that replacement while this rejected + // promise's microtasks were draining. + const replacement = inflightKey ? answerInflight.get(inflightKey) : undefined; + if (replacement && replacement !== existing) { + existing = replacement; + continue; + } + break; } } diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 9b40b93e5..7dd9b67ab 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -4,7 +4,7 @@ import { createBrowserClient } from "@supabase/ssr"; import { type Session, type SupabaseClient } from "@supabase/supabase-js"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { clearPersistedAnswerThread } from "@/lib/answer-thread-storage"; -import { createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; type AuthStatus = "unconfigured" | "loading" | "signed_out" | "authenticated" | "expired" | "error"; @@ -284,7 +284,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const authorizationHeader = useMemo(() => authorizationHeadersForAccessToken(accessToken), [accessToken]); useEffect(() => { - const fingerprint = `${status}:${session?.user.id ?? "anonymous"}:${accessToken ?? "no-token"}`; + // Same-user access-token rotation is not an auth-owner change. Aborting + // uploads or answer streams during routine refresh leaves valid work stale. + const fingerprint = authSessionFingerprint(status, session?.user.id); if (authFingerprintRef.current === null) { authFingerprintRef.current = fingerprint; return; @@ -292,7 +294,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (authFingerprintRef.current === fingerprint) return; authFingerprintRef.current = fingerprint; invalidateAuthRequests(); - }, [accessToken, invalidateAuthRequests, session?.user.id, status]); + }, [invalidateAuthRequests, session?.user.id, status]); const value = useMemo( () => ({ diff --git a/src/proxy.ts b/src/proxy.ts index 16fe19646..e387fcd5f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -25,8 +25,6 @@ import { buildContentSecurityPolicy, resolveRuntimeFlags } from "@/lib/security- const documentFlowRedirects: Record = { "/mockups/document-search-command": "/documents/search", - "/mockups/document-search/source": "/documents/source", - "/mockups/document-search/source/evidence": "/documents/source/evidence", }; // Same runtime flags next.config.ts uses for the static headers, so the nonce'd diff --git a/tests/anonymous-answer-cache-policy.test.ts b/tests/anonymous-answer-cache-policy.test.ts new file mode 100644 index 000000000..561cd2425 --- /dev/null +++ b/tests/anonymous-answer-cache-policy.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { answerCacheAllowedForOwner } from "../src/lib/rag-cache"; + +describe("answer cache privacy policy", () => { + it("requires a stable authenticated owner before answer caching or coalescing", () => { + expect(answerCacheAllowedForOwner(undefined)).toBe(false); + expect(answerCacheAllowedForOwner(null)).toBe(false); + expect(answerCacheAllowedForOwner("")).toBe(false); + expect(answerCacheAllowedForOwner("owner-a")).toBe(true); + }); +}); diff --git a/tests/answer-lifecycle.test.ts b/tests/answer-lifecycle.test.ts index 329851d4b..59e91ee4c 100644 --- a/tests/answer-lifecycle.test.ts +++ b/tests/answer-lifecycle.test.ts @@ -7,4 +7,9 @@ describe("answer lifecycle", () => { expect(answerLifecycleReducer(streaming, { type: "cancel" })).toEqual({ status: "cancelled", query: "lithium" }); expect(answerLifecycleReducer(streaming, { type: "complete" }).status).toBe("completed"); }); + it("returns a cancelled conversation to an empty lifecycle on new chat reset", () => { + const loading = answerLifecycleReducer(initialAnswerLifecycle, { type: "start", query: "lithium" }); + const cancelled = answerLifecycleReducer(loading, { type: "cancel" }); + expect(answerLifecycleReducer(cancelled, { type: "reset" })).toEqual(initialAnswerLifecycle); + }); }); diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index a0e041950..8f46f6710 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -143,6 +143,18 @@ describe("answer render policy", () => { expect(model.copyText).toContain("/documents/doc-1?page=4&chunk=chunk-1"); }); + it("omits the table-evidence heading when displayed evidence has no table rows", () => { + const text = formatAnswerRenderCopyText({ + answerText: "Review the image source.", + trust: "high", + primarySources: [], + warnings: [], + visualEvidence: [visual({ tableRows: undefined, tableColumns: undefined })], + }); + + expect(text).not.toContain("Displayed table evidence"); + }); + it("strips high-yield bold markers from the copy/paste clinical draft", () => { const model = buildAnswerRenderModel( answer({ diff --git a/tests/answer-slo.test.ts b/tests/answer-slo.test.ts index 26c6dfc32..2e7fea7fc 100644 --- a/tests/answer-slo.test.ts +++ b/tests/answer-slo.test.ts @@ -5,10 +5,18 @@ import { answerSloSnapshot, type SloProbeClient } from "@/lib/observability/answ // Fake PostgREST count builder: from().select().gt() is the "total" query; adding // .not(column,...) narrows it to the hybrid-error or degraded count based on the // filtered column. Awaiting resolves to { count, error }. -function fakeClient(counts: { total: number; hybrid: number; degraded: number }, error?: unknown): SloProbeClient { +function fakeClient( + counts: { total: number; hybrid: number; degraded: number }, + error?: unknown, + observedBaseFilters: Array<{ column: string; value: null }> = [], +): SloProbeClient { const build = (filter: "total" | "hybrid" | "degraded") => { const builder = { gt: () => builder, + is: (column: string, value: null) => { + observedBaseFilters.push({ column, value }); + return builder; + }, not: (column: string) => build(column.includes("hybrid_rpc_errors") ? "hybrid" : "degraded"), then: (resolve: (value: { count: number | null; error: unknown }) => unknown) => resolve({ count: error ? null : counts[filter], error: error ?? null }), @@ -31,6 +39,18 @@ describe("answerSloSnapshot", () => { expect(snapshot.degradedRate).toBeCloseTo(0.1, 5); }); + it("counts privacy-redacted answer rows while excluding search observations by event type", async () => { + const observedBaseFilters: Array<{ column: string; value: null }> = []; + const snapshot = await answerSloSnapshot( + fakeClient({ total: 7, hybrid: 1, degraded: 2 }, undefined, observedBaseFilters), + ); + + expect(snapshot.totalQueries).toBe(7); + expect(observedBaseFilters).toEqual( + Array.from({ length: 3 }, () => ({ column: "metadata->>event_type", value: null })), + ); + }); + it("reports zero rates (not NaN) when there are no queries in the window", async () => { const snapshot = await answerSloSnapshot(fakeClient({ total: 0, hybrid: 0, degraded: 0 })); expect(snapshot.totalQueries).toBe(0); diff --git a/tests/auth-request-lifecycle.test.ts b/tests/auth-request-lifecycle.test.ts index c0efa5efb..865a4b95e 100644 --- a/tests/auth-request-lifecycle.test.ts +++ b/tests/auth-request-lifecycle.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { createAuthRequestLifecycle } from "../src/lib/auth-request-lifecycle"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "../src/lib/auth-request-lifecycle"; describe("auth request lifecycle", () => { + it("changes for sign-in/account switches but stays stable across same-user token rotation", () => { + expect(authSessionFingerprint("authenticated", "user-a")).toBe("authenticated:user-a"); + expect(authSessionFingerprint("authenticated", "user-a")).not.toBe( + authSessionFingerprint("authenticated", "user-b"), + ); + expect(authSessionFingerprint("authenticated", "user-a")).not.toBe(authSessionFingerprint("signed_out", null)); + }); + it("aborts registered work and rejects commits from the previous epoch", () => { const lifecycle = createAuthRequestLifecycle(4); const controller = new AbortController(); diff --git a/tests/private-search-scope.test.ts b/tests/private-search-scope.test.ts index f53cdd021..0fb2552fe 100644 --- a/tests/private-search-scope.test.ts +++ b/tests/private-search-scope.test.ts @@ -38,4 +38,14 @@ describe("private search scope", () => { reason: "invalid", }); }); + + it("continues without a scope reference when session storage is unavailable", () => { + const blockedStorage = { + setItem: () => { + throw new DOMException("Storage blocked", "SecurityError"); + }, + }; + + expect(persistPrivateSearchScope(blockedStorage, "owner-a", [documentId], 100, () => scopeRef)).toBeNull(); + }); }); diff --git a/tests/production-mockup-boundary.test.ts b/tests/production-mockup-boundary.test.ts index 4ca870a15..d937444d6 100644 --- a/tests/production-mockup-boundary.test.ts +++ b/tests/production-mockup-boundary.test.ts @@ -21,4 +21,17 @@ describe("production and mockup boundaries", () => { expect(production).toContain("export function GlobalSearchShell"); expect(compatibility).toContain("GlobalSearchShell as GlobalMockupSearchShell"); }); + it("keeps routed differential filters and filtered tool preview wiring", () => { + const differentials = readFileSync( + resolve(process.cwd(), "src/components/differentials/differentials-home-page.tsx"), + "utf8", + ); + const tools = readFileSync( + resolve(process.cwd(), "src/components/tools-page-mockups/tools-page-mockup-page.tsx"), + "utf8", + ); + expect(differentials).toContain("onSuggestedSearch={navigateToSearch}"); + expect(tools).toContain("selected={selectedToolId === tool.id}"); + expect(tools).toContain("onSelect={() => setSelectedToolId(tool.id)}"); + }); }); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index aad8e608e..b5b6152f0 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -20,6 +20,13 @@ function scriptSrcOf(csp: string): string { } describe("proxy content-security-policy", () => { + it("allows dedicated document mockup source routes to render instead of redirecting them", async () => { + for (const path of ["/mockups/document-search/source", "/mockups/document-search/source/evidence"]) { + const response = await proxy(requestFor(path)); + expect(response.headers.get("location")).toBeNull(); + } + }); + it("emits a per-request nonce with strict-dynamic and no unsafe-inline (production shape)", async () => { const res = await proxy(requestFor("/")); const csp = res.headers.get("content-security-policy"); diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts index 43f6386e6..aa121f5b9 100644 --- a/tests/rag-answer-fallback.test.ts +++ b/tests/rag-answer-fallback.test.ts @@ -1255,18 +1255,24 @@ describe("RAG structured-output fallback", () => { logQuery: false, signal: controller.signal, }); - // A second identical request coalesces onto the (now-doomed) in-flight promise. + // Two identical waiters coalesce onto the (now-doomed) in-flight promise. const second = answerQuestionWithScope({ query: "Summarize inpatient approach", ownerId: "owner-1", logQuery: false, }); + const third = answerQuestionWithScope({ + query: "Summarize inpatient approach", + ownerId: "owner-1", + logQuery: false, + }); // The originator's failure stays with the originator... await expect(first).rejects.toBeTruthy(); // ...and the coalesced caller still gets a real, independently generated answer rather than a 500. - const secondAnswer = await second; + const [secondAnswer, thirdAnswer] = await Promise.all([second, third]); expect(secondAnswer.openAIRequestIds).toEqual(["req_independent"]); + expect(thirdAnswer.openAIRequestIds).toEqual(["req_independent"]); expect(secondAnswer.routingReason ?? "").not.toContain("answer_inflight_coalesced"); // It ran its OWN pipeline (search + generation once) instead of cloning the failed one. expect(generateStructuredTextResult).toHaveBeenCalledTimes(1); diff --git a/tests/resolve-tsx-cli.test.ts b/tests/resolve-tsx-cli.test.ts new file mode 100644 index 000000000..a08a9321a --- /dev/null +++ b/tests/resolve-tsx-cli.test.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveTsxCli } from "../scripts/resolve-tsx-cli.mjs"; + +const created: string[] = []; +afterEach(() => { + for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("resolveTsxCli", () => { + it("falls back from a secondary worktree to the main checkout node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "tsx-worktree-")); + created.push(root); + const main = join(root, "repo"); + const secondary = join(root, "secondary"); + const gitDir = join(main, ".git"); + const secondaryGitDir = join(gitDir, "worktrees", "secondary"); + const cli = join(main, "node_modules", "tsx", "dist", "cli.mjs"); + mkdirSync(secondaryGitDir, { recursive: true }); + mkdirSync(secondary, { recursive: true }); + mkdirSync(join(main, "node_modules", "tsx", "dist"), { recursive: true }); + writeFileSync(cli, ""); + writeFileSync(join(secondary, ".git"), `gitdir: ${secondaryGitDir}`); + writeFileSync(join(secondaryGitDir, "gitdir"), join(secondary, ".git")); + + expect( + resolveTsxCli(secondary, () => { + throw new Error("local resolution unavailable"); + }), + ).toBe(cli); + }); +}); diff --git a/tests/upload-outcome.test.ts b/tests/upload-outcome.test.ts index 3f2c160f6..705f1455e 100644 --- a/tests/upload-outcome.test.ts +++ b/tests/upload-outcome.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { uploadOutcomeFromResponse } from "../src/components/clinical-dashboard/DocumentManagerPanel"; +import { + uploadBatchCompletion, + uploadOutcomeFromResponse, +} from "../src/components/clinical-dashboard/DocumentManagerPanel"; describe("upload outcomes", () => { it("distinguishes queued, duplicate, and failed server responses", () => { @@ -34,4 +37,19 @@ describe("upload outcomes", () => { message: "File exceeds 150 MB upload limit.", }); }); + + it("refreshes and clears the input when a batch is partly queued and partly failed", () => { + const completion = uploadBatchCompletion([ + uploadOutcomeFromResponse("fresh.pdf", 201, { document: { id: "doc-1" }, job: { id: "job-1" } }), + uploadOutcomeFromResponse("large.pdf", 413, { + code: "payload_too_large", + message: "File exceeds 150 MB upload limit.", + }), + ]); + + expect(completion.queued).toHaveLength(1); + expect(completion.failures).toHaveLength(1); + expect(completion.shouldRefreshDocuments).toBe(true); + expect(completion.shouldClearInput).toBe(true); + }); }); diff --git a/tests/yaml-contract.test.ts b/tests/yaml-contract.test.ts new file mode 100644 index 000000000..a99f38349 --- /dev/null +++ b/tests/yaml-contract.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { yamlBlock } from "../scripts/yaml-contract.mjs"; + +describe("yamlBlock", () => { + it("scopes properties to the requested job and step instead of comments or sibling jobs", () => { + const yaml = `jobs: + decoy: + # continue-on-error: true + steps: + - name: Semgrep scan + run: echo decoy + semgrep: + steps: + - name: Checkout + run: echo checkout + - name: Semgrep scan + continue-on-error: true + run: semgrep scan src + later: + continue-on-error: true +`; + const job = yamlBlock(yaml, "semgrep:", 2); + const scan = yamlBlock(job, "- name: Semgrep scan", 6); + expect(job).not.toContain("echo decoy"); + expect(job).not.toContain("later:"); + expect(scan).toContain("continue-on-error: true"); + expect(scan).toContain("semgrep scan src"); + }); +});