Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gui/src/styles-usage-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions gui/tests/usage-custom-range.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand All @@ -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) {
Expand Down
90 changes: 90 additions & 0 deletions gui/tests/usage-scroll-browser.ts
Original file line number Diff line number Diff line change
@@ -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=<id> 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("<!doctype html><title>Usage scroll regression</title>", {
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) => `<tr>
<td>model-${i}</td><td>Provider</td><td>Share</td>
${"<td>12345</td>".repeat(8)}
<td><span class="usage-hit-rate">94%</span><span class="sr-only">Cache detail coverage for model ${i}</span></td>
</tr>`).join("");
return new Response(`<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="/styles.css"></head><body><div id="root"><div class="app">
<aside class="sidebar">Navigation</aside><main class="main"><div class="main-inner">
<div class="usage-workspace-shell"><section class="usw-section">
<h2>Models</h2><div class="tbl-wrap"><table class="tbl usage-models-tbl">
<thead><tr>${"<th>Column</th>".repeat(12)}</tr></thead><tbody>${rows}</tbody></table></div>
</section><section class="usw-section" style="height:300px"><h2>Coverage</h2></section>
</div></div></main></div></div></body></html>`, { 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);
}
10 changes: 10 additions & 0 deletions gui/tests/usage-scroll-containment.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
2 changes: 1 addition & 1 deletion structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading