From 6e230610223905d9eec6f45857758b5e13eaf373 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:42:52 +0200 Subject: [PATCH 1/5] test(lab): cover compatibility follow-up regressions --- gui/tests/compatibility-lab-followup.test.tsx | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 gui/tests/compatibility-lab-followup.test.tsx diff --git a/gui/tests/compatibility-lab-followup.test.tsx b/gui/tests/compatibility-lab-followup.test.tsx new file mode 100644 index 0000000000..50faadc42c --- /dev/null +++ b/gui/tests/compatibility-lab-followup.test.tsx @@ -0,0 +1,181 @@ +/** @jsxImportSource react */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import CompatibilityMatrix from "../src/pages/CompatibilityMatrix"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +const originalFetch = globalThis.fetch; +let restoreGlobals: (() => void) | undefined; +let previousLanguageDescriptor: PropertyDescriptor | undefined; +let testWindow: Window; + +const API_BASE = "http://127.0.0.1:4096"; + +const STATUS_AVAILABLE = { + projectionAvailable: true, + subjectCount: 1, + verdictCount: 1, + observationCount: 0, + eventCount: 2, + builtAtMs: 1_700_000_000_000, +}; + +const SUBJECTS = { + subjects: [{ subjectId: "subject-alpha", subjectKind: "protocol" }], + hasMore: false, +}; + +const SUBJECT_DETAIL = { + subject: { + subjectKind: "protocol", + subjectSchemaVersion: 1, + inboundProtocol: "openai-chat", + }, +}; + +const EVENT_DETAIL = { + event: { + eventKind: "observation", + eventId: "e1", + recordedAt: 1_700_000_000_040, + producer: "lab", + producerVersion: "1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + outcome: "pass", + excluded: false, + exclusionReason: null, + }, +}; + +function verdictPage(eventIds: string[]) { + return { + verdicts: [{ + projectionKey: "k1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + suiteVersion: "1", + suiteManifestDigest: "digest-a", + projectionSpecVersion: "cl-02.v1", + verdict: "VERIFIED", + asOf: 1_700_000_000_100, + scenarioManifestDigests: [], + claimSourceDigest: null, + contributingEventIds: eventIds, + contradictingEventIds: [], + notes: [], + }], + hasMore: true, + nextCursor: "cursor-2", + }; +} + +type FetchOptions = { + failLoadMore?: boolean; + partialEvents?: boolean; +}; + +function installLabFetch(opts: FetchOptions = {}) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/lab/status")) return Response.json(STATUS_AVAILABLE); + if (url.includes("/api/lab/verdicts")) { + if (url.includes("cursor=cursor-2")) { + if (opts.failLoadMore) return new Response("unavailable", { status: 503 }); + return Response.json({ verdicts: [], hasMore: false }); + } + return Response.json(verdictPage(opts.partialEvents ? ["e1", "missing-event"] : ["e1"])); + } + if (url.includes("/api/lab/subjects/subject-alpha")) return Response.json(SUBJECT_DETAIL); + if (url.includes("/api/lab/subjects")) return Response.json(SUBJECTS); + if (url.includes("/api/lab/observations")) return Response.json({ observations: [], hasMore: false }); + if (url.includes("/api/lab/events/e1")) return Response.json(EVENT_DETAIL); + if (url.includes("/api/lab/events/missing-event")) return new Response("gone", { status: 404 }); + if (url.includes("/api/lab/artifacts/")) return new Response("gone", { status: 404 }); + if (url.includes("/api/lab/production-signals")) return new Response("gone", { status: 404 }); + return new Response("{}", { status: 404 }); + }) as typeof fetch; +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + testWindow = new Window({ url: "http://localhost/#models/compatibility" }); + previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" }); + const keys = ["document", "window", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries( + keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as Record<(typeof keys)[number], PropertyDescriptor | undefined>; + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + restoreGlobals = () => { + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + if (previousLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor); + } + }; +}); + +afterEach(() => { + restoreGlobals?.(); + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +async function renderMatrix(): Promise<{ root: Root; container: HTMLDivElement }> { + const { createRoot } = await import("react-dom/client"); + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + return { root, container }; +} + +test("load-more failures stay visible while the loaded rows remain retryable", async () => { + installLabFetch({ failLoadMore: true }); + const { root, container } = await renderMatrix(); + await waitFor(() => container.querySelector(".lab-load-more button") !== null); + const button = container.querySelector(".lab-load-more button") as HTMLButtonElement; + await act(async () => { button.click(); }); + await waitFor(() => container.querySelector(".notice-err")?.textContent?.includes("HTTP 503") ?? false); + expect(container.textContent).toContain("Verified"); + expect(container.querySelector(".lab-load-more button")).not.toBeNull(); + await act(async () => root.unmount()); +}); + +test("verdict detail reports when referenced evidence events are only partially available", async () => { + installLabFetch({ partialEvents: true }); + const { root, container } = await renderMatrix(); + await waitFor(() => container.querySelector('button[data-verdict-detail="k1"]') !== null); + const button = container.querySelector('button[data-verdict-detail="k1"]') as HTMLButtonElement; + await act(async () => { button.click(); }); + await waitFor(() => container.querySelector(".lab-detail-pane")?.textContent?.includes("Evidence events (1/2)") ?? false); + expect(container.querySelector(".lab-detail-pane")?.textContent).toContain("Evidence events (1/2)"); + await act(async () => root.unmount()); +}); From 5f59416cf1a588c8440ee676d6af5a2e04784e54 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:46:03 +0200 Subject: [PATCH 2/5] fix(lab): surface partial compatibility reads --- gui/src/pages/CompatibilityMatrix.tsx | 39 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index 9fcac9b129..e6084d8086 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -62,6 +62,12 @@ type ExtraVerdictPage = { hasMore: boolean; }; +type LoadMoreFailure = { + baseData: LabPageData; + queryKey: string; + message: string; +}; + function localizedFetchError(e: unknown, fallback: string): string { if (!(e instanceof Error)) return fallback; const msg = e.message; @@ -171,6 +177,11 @@ function DetailPane({ locale: Parameters[0]; onClose: () => void; }) { + const expectedEventCount = new Set([ + ...verdict.contributingEventIds, + ...verdict.contradictingEventIds, + ]).size; + return (