From 0953e8310bac3926a6adb90123903b4d7284e5ad Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:52:40 -0700 Subject: [PATCH 1/2] Add tests for Files chip legibility, upload confirmation, toast position, and detail-pane height Covers four owner-reported gaps on the Files page: the scope chips reading as one ambiguous group, an upload with no success feedback, the app-wide toast sitting bottom-center, and the file detail pane floating short in an otherwise empty page. --- apps/web/test/library-page-selection.test.tsx | 50 ++++++ .../test/library-upload-confirmation.test.tsx | 169 ++++++++++++++++++ apps/web/test/toast-single-system.test.tsx | 32 ++++ 3 files changed, 251 insertions(+) create mode 100644 apps/web/test/library-upload-confirmation.test.tsx diff --git a/apps/web/test/library-page-selection.test.tsx b/apps/web/test/library-page-selection.test.tsx index 242c713e..a9f3753a 100644 --- a/apps/web/test/library-page-selection.test.tsx +++ b/apps/web/test/library-page-selection.test.tsx @@ -314,4 +314,54 @@ describe("LibraryPage top-nav action placement", () => { }); expect(container.querySelector('[data-slot="table"]')).not.toBeNull(); }); + + test("the workbench-scope pair reads as one grouped control, distinct from the clear-selection action", () => { + act(() => { + root.render( + undefined} + workbenchScope={{ title: "Launch plan" }} + scope="all" + onScopeChange={() => undefined} + />, + ); + }); + const topBarActions = container.querySelector( + '[data-testid="stage-top-bar-actions"]', + ); + // The two-state scope toggle is visually one control (a bordered + // segmented group, the same idiom `ViewToggle` already uses in this + // bar) rather than two stray buttons that read as independent chips. + const scopeGroup = topBarActions?.querySelector( + '[aria-label="Files scope"]', + ); + expect(scopeGroup?.className).toContain("border"); + expect(scopeGroup?.textContent).toContain("Launch plan"); + expect(scopeGroup?.textContent).toContain("All workbenches"); + + // The clear-selection action is not a filter and must not read as one: + // no button labelled bare "All" sits beside "All workbenches". + const buttons = [...(topBarActions?.querySelectorAll("button") ?? [])]; + expect(buttons.some((b) => b.textContent?.trim() === "All")).toBe(false); + expect(buttons.some((b) => b.textContent?.trim() === "Back to files")).toBe( + true, + ); + }); + + test("the file detail pane fills the available height", () => { + act(() => { + root.render( + undefined} + />, + ); + }); + const pane = container.querySelector("aside"); + expect(pane).not.toBeNull(); + expect(pane?.className).toContain("h-full"); + }); }); diff --git a/apps/web/test/library-upload-confirmation.test.tsx b/apps/web/test/library-upload-confirmation.test.tsx new file mode 100644 index 00000000..bf89a2c1 --- /dev/null +++ b/apps/web/test/library-upload-confirmation.test.tsx @@ -0,0 +1,169 @@ +// The owner reported uploading a file with no way to tell it worked. The +// fix must be an honest confirmation: it names the file the server actually +// stored, not the one the browser happened to send, and it stays silent +// (surfacing the failure instead) when the upload endpoint reports one. +// A sibling lane is fixing a real bug where uploaded content reads back +// empty — this suite only pins that the toast is grounded in the upload +// response, never a blind echo of the local `File` picked. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { BenchProvider } from "../src/bench-context"; +import { NavigationProvider } from "../src/navigation"; +import { LibraryRoute } from "../src/pages/library-page"; +import { spyOnReactUiToast } from "./react-ui-toast-mock"; +import { TestQueryProvider } from "./test-query-provider"; + +const toastMock = spyOnReactUiToast(); +const noop = () => undefined; +const realFetch = globalThis.fetch; + +const membership = { + principalId: "prn_1", + tenantId: "tnt_1", + tenantName: "Test Bench", + tenantSlug: "test-bench", + kind: "user", + status: "active", + roles: [], +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function uploadFile(container: HTMLDivElement, file: File): void { + const input = container.querySelector( + 'input[aria-label="Upload files"]', + ) as HTMLInputElement | null; + if (input === null) throw new Error("no upload input"); + Object.defineProperty(input, "files", { + configurable: true, + value: [file], + }); + act(() => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("Files upload confirmation", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + toastMock.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + window.localStorage.clear(); + }); + + async function settle(until: () => boolean): Promise { + for (let i = 0; i < 30; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (until()) return; + } + } + + function renderRoute(): void { + act(() => { + root.render( + + + + + + + , + ); + }); + } + + test("a completed upload confirms the title the server stored, not the local file name", async () => { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/api/me/principals")) { + return Promise.resolve( + jsonResponse({ data: [membership], nextCursor: null }), + ); + } + if (url.includes("/artifacts/upload") && init?.method === "POST") { + return Promise.resolve( + jsonResponse({ + data: [ + { + id: "art_new", + kind: "document", + // The server renamed it on collision — the confirmation + // must say this, not the "draft.txt" the browser sent. + title: "draft (1).txt", + source: {}, + version: 1, + ownerPrincipalId: null, + ownerName: null, + content: "", + archivedAt: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ], + }), + ); + } + if (url.includes("/artifacts")) { + return Promise.resolve(jsonResponse({ data: [], nextCursor: null })); + } + return Promise.reject(new Error(`unrouted fetch: ${url}`)); + }) as typeof fetch; + + renderRoute(); + await settle(() => container.querySelector('input[type="file"]') !== null); + + uploadFile(container, new File(["hi"], "draft.txt")); + await settle(() => toastMock.mock.calls.length > 0); + + expect(toastMock).toHaveBeenCalledWith("Uploaded · draft (1).txt"); + expect(container.textContent).not.toContain("draft.txt"); + }); + + test("a failed upload surfaces the failure instead of a fake success toast", async () => { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/api/me/principals")) { + return Promise.resolve( + jsonResponse({ data: [membership], nextCursor: null }), + ); + } + if (url.includes("/artifacts/upload") && init?.method === "POST") { + return Promise.resolve(jsonResponse({ error: "boom" }, 500)); + } + if (url.includes("/artifacts")) { + return Promise.resolve(jsonResponse({ data: [], nextCursor: null })); + } + return Promise.reject(new Error(`unrouted fetch: ${url}`)); + }) as typeof fetch; + + renderRoute(); + await settle(() => container.querySelector('input[type="file"]') !== null); + + uploadFile(container, new File(["hi"], "draft.txt")); + await settle(() => container.querySelector('[role="alert"]') !== null); + + expect(toastMock).not.toHaveBeenCalled(); + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toBeTruthy(); + }); +}); diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index ecec0ac6..226e97b4 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -205,4 +205,36 @@ describe("the one toast system (CL-6372)", () => { }); expect(visibleToasts().length).toBe(0); }); + + // The owner's earlier screenshots showed the toast bottom-center, easy to + // miss against page content. main.tsx (the app's one `` mount) + // now passes `position="bottom-right"` — asserted here the same way the + // house-styling test above asserts the default, so a regression back to + // center fails this instead of only showing up in a screenshot. + test("the app's Toaster mounts bottom-right, not the library default bottom-center", async () => { + stubFailingCreate(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + undefined}> + + + + + + , + ); + }); + + act(() => toast("Uploaded · draft.txt")); + await settle(); + + const region = document.body.querySelector("[data-sonner-toaster]"); + expect(region?.getAttribute("data-y-position")).toBe("bottom"); + expect(region?.getAttribute("data-x-position")).toBe("right"); + await waitForClear(); + }); }); From eeabba809ac77bf05fd0be607c98e44a3ce19260 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:52:48 -0700 Subject: [PATCH 2/2] Files: legible scope chips, honest upload confirmation, bottom-right toast, full-height detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The top bar's clear-selection action no longer says bare "All" beside "All workbenches" — it reads "Back to files", an action rather than a third filter option. The workbench/all-workbenches pair is now visually one bordered segmented control (the same idiom `ViewToggle` already uses in this bar), so the one real scope question reads as one control. - A completed upload now confirms the title the server actually stored, not the local file name the browser sent, and stays silent (surfacing the failure instead) when the upload endpoint reports one. - The app's one `` mount (main.tsx) now positions bottom-right; a react-ui override that hardcoded horizontal centering is cancelled for that position in app.css so the toast actually lands where it claims to. - The file preview pane now fills the available height instead of floating as a short panel in an otherwise empty page. --- apps/web/src/app.css | 15 +++++++++++++ apps/web/src/main.tsx | 2 +- apps/web/src/pages/library-page.tsx | 33 +++++++++++++++++++++++------ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 2e00c37c..f44313b9 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -12,6 +12,21 @@ @import "@corbits/tasks-ui/styles.css"; @import "@corbits/plugins-ui/styles.css"; +/* react-ui's `.corbits-toast` ships a hardcoded horizontal-center transform + (`left: 50%; transform: translateX(-50%) !important`) regardless of the + `position` prop passed to `` — main.tsx now mounts it + bottom-right, and without this the toast would still visually sit + centered while claiming to be on the right. Sonner itself already + right-aligns the toast via its own `[data-x-position="right"]` rule + (injected at runtime, unrelated to this stylesheet); this just stops + react-ui's override from fighting it. Higher specificity than that rule + wins the cascade on its own, so this is safe regardless of load order. */ +[data-sonner-toaster][data-x-position="right"] + [data-sonner-toast].corbits-toast { + left: auto !important; + transform: none !important; +} + /* Mock chrome: 15px root so rem-based density matches the interactive mock. */ html { font-size: 15px; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 96f591d2..bca0b99d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -114,7 +114,7 @@ function Root() { provisioningErrorRefId={provisioningError?.refId} onRetryProvisioning={handleRetryProvisioning} /> - + ); } diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 14e26c6b..7f6b8b43 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -251,7 +251,7 @@ function PreviewPane({ ? artifactPreviewPath(tenantId, detail.id) : undefined; return ( -