diff --git a/apps/web/package.json b/apps/web/package.json index 2d21902e8..20cd99b1c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,7 +47,6 @@ "lucide-react": "^1.27.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "sonner": "^2.0.7", "yjs": "^13.6.32" }, "devDependencies": { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 94e124de3..107d31aed 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -7,7 +7,6 @@ import { BootScreen, Button, CorbitsMark, EmptyState } from "@corbits/react-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import { CircleAlert } from "lucide-react"; import { useEffect, useMemo } from "react"; -import { Toaster } from "sonner"; import { AuthScreen } from "./auth-screen"; import { BenchProvider } from "./bench-context"; @@ -121,7 +120,6 @@ function Shell({ )} - diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx new file mode 100644 index 000000000..38202463c --- /dev/null +++ b/apps/web/test/toast-single-system.test.tsx @@ -0,0 +1,196 @@ +// CL-6372: a failed workbench create used to fire two toasts — the house +// `` from `@corbits/react-ui` (mounted in main.tsx) and a second, +// unstyled `` imported straight from `sonner` (mounted in +// app.tsx). Sonner's `toast()` renders into every mounted ``, so +// one `toast()` call rendered twice: the tokened grey box bottom-center from +// react-ui, and a default white pill bottom-right from the raw sonner +// mount. app.tsx no longer imports `sonner` at all — this file pins that a +// single `` mount produces exactly one toast per call, carries +// the house styling, and clears itself. + +import { toast, Toaster } from "@corbits/react-ui"; +import { afterEach, 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 { NewWorkbenchPickerRoute } from "../src/pages/new-workbench-picker"; +import { TestQueryProvider } from "./test-query-provider"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const MEMBERSHIP = { + data: [ + { + principalId: "prn_1", + tenantId: "tnt_1", + tenantName: "Corbits Bench", + tenantSlug: "corbits-bench", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, +}; + +function stubFailingCreate(): void { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/api/me/principals")) { + return Promise.resolve(json(MEMBERSHIP)); + } + if (path.includes("/workflows/definitions")) { + return Promise.resolve(json({ data: [], nextCursor: null })); + } + return Promise.resolve(json({ error: "boom" }, 500)); + }) as typeof fetch; +} + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) { + act(() => root?.unmount()); + root = null; + } + if (container !== null) { + container.remove(); + container = null; + } + document + .querySelectorAll("[data-sonner-toaster]") + .forEach((node) => node.remove()); +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const settle = () => act(() => sleep(10)); + +function visibleToasts(): NodeListOf { + return document.body.querySelectorAll("[data-sonner-toast]"); +} + +// Sonner keeps its toast store globally, independent of any one `` mount — an un-dismissed toast from one test reappears the moment the +// next test's fresh Toaster subscribes. Every test that leaves a toast +// showing waits out the full display duration before finishing. +async function waitForClear(): Promise { + await act(async () => { + await sleep(2400); + }); +} + +async function renderPickerWithToaster(): Promise { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + undefined}> + + + + + + , + ); + }); + for (let i = 0; i < 20; i++) { + await settle(); + if (container.querySelector('[role="radiogroup"]') !== null) break; + } +} + +describe("the one toast system (CL-6372)", () => { + test("a failed workbench create fires exactly one toast", async () => { + stubFailingCreate(); + await renderPickerWithToaster(); + + const createButton = Array.from( + container?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent === "Create workbench"); + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + for (let i = 0; i < 30; i++) { + await settle(); + if (visibleToasts().length > 0) break; + } + + const shown = visibleToasts(); + expect(shown.length).toBe(1); + expect(shown[0]?.textContent).toBe( + "Couldn't create the workbench — try again.", + ); + await waitForClear(); + }); + + test("the failure toast carries the house styling, not sonner's default", async () => { + stubFailingCreate(); + await renderPickerWithToaster(); + + const createButton = Array.from( + container?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent === "Create workbench"); + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + for (let i = 0; i < 30; i++) { + await settle(); + if (visibleToasts().length > 0) break; + } + + const shown = visibleToasts()[0]; + expect(shown?.classList.contains("corbits-toast")).toBe(true); + expect(shown?.getAttribute("data-styled")).toBe("false"); + const region = document.body.querySelector("[data-sonner-toaster]"); + expect(region?.getAttribute("data-y-position")).toBe("bottom"); + expect(region?.getAttribute("data-x-position")).toBe("center"); + await waitForClear(); + }); + + test("a second toast dismisses the first instead of stacking", async () => { + stubFailingCreate(); + await renderPickerWithToaster(); + + act(() => toast("First")); + await settle(); + act(() => toast("Second")); + await settle(); + + const staying = document.body.querySelectorAll( + '[data-sonner-toast][data-removed="false"]', + ); + expect(staying.length).toBe(1); + expect(staying[0]?.textContent).toBe("Second"); + await waitForClear(); + }); + + test("the toast dismisses itself after its display duration", async () => { + stubFailingCreate(); + await renderPickerWithToaster(); + + act(() => toast("Grant revoked")); + await settle(); + expect(visibleToasts().length).toBe(1); + + await act(async () => { + await sleep(2400); + }); + expect(visibleToasts().length).toBe(0); + }); +}); diff --git a/bun.lock b/bun.lock index 52d16c2aa..9c4c6b5dc 100644 --- a/bun.lock +++ b/bun.lock @@ -151,7 +151,6 @@ "lucide-react": "^1.27.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "sonner": "^2.0.7", "yjs": "^13.6.32", }, "devDependencies": { @@ -3374,6 +3373,8 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],