diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 91485e1a217..0acad7cf267 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -147,6 +147,8 @@ } .usw-section .tbl-wrap { + /* Keep absolute screen-reader captions inside the table's scrollable overflow. */ + position: relative; min-width: 0; /* Inset table from border+surface so cells aren't flush to the frame. */ padding: var(--space-3); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 8a698586188..4b60483cbda 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -10,7 +10,7 @@ import Usage from "../src/pages/Usage"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT"] as const; const originalFetch = globalThis.fetch; -let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; let root: Root | undefined; let container: HTMLElement; @@ -20,7 +20,7 @@ type RequestGate = { url: string; resolve: (response: Response) => void }; let requests: RequestGate[]; beforeEach(() => { - previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousGlobals = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previousGlobals; clearClientResourceStoresForTests(); testWindow = new Window({ url: "http://localhost/" }); testWindow.localStorage.setItem("ocx-lang", "en"); @@ -47,7 +47,11 @@ afterEach(async () => { globalThis.fetch = originalFetch; clearClientResourceStoresForTests(); testWindow.close(); - for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } }); async function mount(connected = false) { diff --git a/gui/tests/usage-scroll-browser.ts b/gui/tests/usage-scroll-browser.ts new file mode 100644 index 00000000000..52452c5fe50 --- /dev/null +++ b/gui/tests/usage-scroll-browser.ts @@ -0,0 +1,90 @@ +/** Rendered regression for Usage's nested table scroller and screen-reader captions. + * Run with an active browser-skill Agent Window: BSK_SESSION= bun tests/usage-scroll-browser.ts + * Uses the real source stylesheets and synthetic rows; never connects to a user's proxy. + * Unlike happy-dom, the browser measures overflow from absolutely positioned descendants. + */ +import { resolve, sep } from "node:path"; + +const session = process.env.BSK_SESSION; +if (!session) throw new Error("Set BSK_SESSION to an active browser-skill Agent Window session."); +const src = resolve(import.meta.dir, "../src"); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/") return new Response("Usage scroll regression", { + headers: { "content-type": "text/html" }, + }); + if (url.pathname === "/fixture") { + const count = url.searchParams.get("rows") === "long" ? 40 : 2; + const rows = Array.from({ length: count }, (_, i) => ` + model-${i}ProviderShare + ${"12345".repeat(8)} + 94%Cache detail coverage for model ${i} + `).join(""); + return new Response(` +
+
+
+

Models

+ ${"".repeat(12)}${rows}
Column
+

Coverage

+
`, { headers: { "content-type": "text/html" } }); + } + const filePath = resolve(src, `.${url.pathname}`); + if (filePath.startsWith(`${src}${sep}`) && filePath.endsWith(".css")) { + const file = Bun.file(filePath); + if (await file.exists()) return new Response(file); + } + return new Response("Not found", { status: 404 }); + }, +}); + +async function browser(command: string, ...args: string[]) { + const process = Bun.spawn(["bsk", command, ...args, "--session", session!], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited, + ]); + if (exitCode !== 0) throw new Error(stderr || stdout); + return stdout; +} + +try { + await browser("navigate", server.url.href); + const results = JSON.parse(await browser("evaluate", `(async () => { + const results = []; + for (const width of [1280, 390]) for (const rows of ['short', 'long']) { + const frame = document.createElement('iframe'); + frame.style.cssText = 'width:' + width + 'px;height:750px;border:0'; + frame.src = '/fixture?rows=' + rows; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Fixture load timed out')), 5000); + frame.onload = () => { clearTimeout(timeout); resolve(); }; + document.body.append(frame); + }); + const d = frame.contentDocument, w = frame.contentWindow; + const main = d.querySelector('main'), wrap = d.querySelector('.tbl-wrap'); + // A short report may naturally end above the viewport bottom; only scrolling + // beyond that initial end (or the viewport for long reports) is blank overflow. + const expectedBottom = Math.min(w.innerHeight, main.getBoundingClientRect().bottom); + d.body.scrollTop = d.body.scrollHeight; + d.documentElement.scrollTop = d.documentElement.scrollHeight; + const blankBeyondMain = Math.max(0, expectedBottom - main.getBoundingClientRect().bottom); + wrap.scrollTop = wrap.scrollHeight; + const lastRow = d.querySelector('tbody tr:last-child').getBoundingClientRect(); + const bounds = wrap.getBoundingClientRect(); + const lastRowReachable = lastRow.bottom <= bounds.bottom + 1 && lastRow.top >= bounds.top; + const captionsPresent = d.querySelectorAll('.sr-only').length === (rows === 'long' ? 40 : 2); + const tableScrolls = rows !== 'long' || wrap.scrollTop > 0; + results.push({ width, rows, blankBeyondMain, lastRowReachable, captionsPresent, tableScrolls, + pass: blankBeyondMain <= 1 && lastRowReachable && captionsPresent && tableScrolls }); + frame.remove(); + } + return { results, pass: results.every(result => result.pass) }; + })()`)); + console.log(JSON.stringify(results, null, 2)); + if (results.pass !== true) process.exitCode = 1; +} finally { + server.stop(true); +} diff --git a/gui/tests/usage-scroll-containment.test.ts b/gui/tests/usage-scroll-containment.test.ts new file mode 100644 index 00000000000..155783d984f --- /dev/null +++ b/gui/tests/usage-scroll-containment.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { effectiveDeclaration, withoutComments } from "./helpers/css-declarations"; + +test("Usage table scrollports contain absolutely positioned accessible captions", async () => { + const css = withoutComments(await Bun.file(new URL("../src/styles-usage-workspace.css", import.meta.url)).text()); + // Source contract only: happy-dom cannot measure overflow. The rendered + // regression in usage-scroll-browser.ts proves the blank-scroll failure. + expect(effectiveDeclaration(css, ".usw-section .tbl-wrap", "position")).toBe("relative"); + expect(effectiveDeclaration(css, ".usw-section .tbl-wrap", "overflow-y")).toBe("auto"); +}); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 4a3c0667c74..3c0651f9f7a 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -292,7 +292,7 @@ surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibil bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. -The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. +The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md)