feat(web): toasts on sonner behind the same toast() API; specs pin behavior via expectToast

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 12:37:46 -07:00
co-authored by Claude Fable 5
parent 407c443a40
commit 65c4a29bf8
10 changed files with 46 additions and 63 deletions
+10 -10
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, ADMIN, MEMBER } from "./helpers";
import { login, wikiId, ADMIN, MEMBER, expectToast } from "./helpers";
// Phase 4: org admin (rename, members, projects, invites, share audit) and
// hub settings (policy toggles, pending queue). Panels are not routes —
@@ -24,7 +24,7 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p
// Rename and revert
await page.fill("#org-rename", "renamed-org");
await page.click("#org-rename-btn");
await expect(page.locator("#toast")).toContainText("Renamed");
await expectToast(page, "Renamed");
await page.click("#account-btn");
await expect(page.locator("#menu-org-settings")).toContainText("renamed-org");
await page.keyboard.press("Escape");
@@ -40,7 +40,7 @@ test("org admin: member role change round-trip", async ({ page }) => {
await openOrgSettings(page);
const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select");
await sel.selectOption("owner");
await expect(page.locator("#toast")).toContainText("Role updated");
await expectToast(page, "Role updated");
await expect(sel).toHaveValue("owner");
await sel.selectOption("member");
await expect(sel).toHaveValue("member");
@@ -50,13 +50,13 @@ test("org admin: invite create shows in list, revoke removes it", async ({ page
await login(page);
await openOrgSettings(page);
await page.click(".admin-h .pbtn"); // New invite
await expect(page.locator("#toast")).toContainText("Invite");
await expectToast(page, "Invite");
const row = page.locator(".admin-item", { hasText: "/join/" }).first();
await expect(row).toBeVisible();
await expect(row.locator(".ai-tag")).toContainText("unused");
await row.locator(".ai-del").click();
await page.click(".modal .danger-btn"); // confirm revoke
await expect(page.locator("#toast")).toContainText("Revoked");
await expectToast(page, "Revoked");
await expect(page.locator(".admin-item", { hasText: "/join/" })).toHaveCount(0);
});
@@ -70,7 +70,7 @@ test("org admin: public share audit lists and revokes", async ({ page }) => {
await expect(row.locator(".ai-tag")).toContainText("wiki");
await row.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expect(page.locator("#toast")).toContainText("Share revoked");
await expectToast(page, "Share revoked");
await expect(page.locator(".admin-item", { hasText: "index.md" })).toHaveCount(0);
});
@@ -83,12 +83,12 @@ test("org admin: project rename and delete", async ({ page }) => {
await row.locator(".ai-btn", { hasText: "Rename" }).click();
await page.fill(".modal-input", "doomed-2");
await page.click(".modal .pbtn");
await expect(page.locator("#toast")).toContainText("Renamed");
await expectToast(page, "Renamed");
const row2 = page.locator(".admin-item", { hasText: "doomed-2" });
await expect(row2).toBeVisible();
await row2.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expect(page.locator("#toast")).toContainText("Deleted");
await expectToast(page, "Deleted");
await expect(page.locator(".admin-item", { hasText: "doomed-2" })).toHaveCount(0);
await expect(page.locator("#projects .row .label", { hasText: "doomed-2" })).toHaveCount(0);
});
@@ -117,10 +117,10 @@ test("hub settings: policy view, save round-trip, pending queue empty", async ({
const app = page.locator(".admin-item.toggle").nth(1).locator("input");
await app.check();
await page.click(".admin > .pbtn");
await expect(page.locator("#toast")).toContainText("policy saved");
await expectToast(page, "policy saved");
await app.uncheck();
await page.click(".admin > .pbtn");
await expect(page.locator("#toast")).toContainText("policy saved");
await expectToast(page, "policy saved");
await expect(page.locator(".admin-empty", { hasText: "No one is waiting" })).toBeVisible();
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
import { login, wikiId, expectToast } from "./helpers";
// Phase 2: tree, folder listings (heat dots + change feed), file views
// (markdown/wikilinks/images), breadcrumbs, upload, share, palette.
@@ -118,7 +118,7 @@ test("share mints a public link that serves the file, revoke kills it", async ({
expect(publicRes.status()).toBe(200);
expect(await publicRes.text()).toContain("Second version");
await page.click(".modal .ai-del"); // revoke
await expect(page.locator("#toast")).toContainText("revoked");
await expectToast(page, "revoked");
const gone = await page.request.get(url!);
expect(gone.status()).toBe(404);
});
+7
View File
@@ -33,3 +33,10 @@ export async function wikiId(page: Page): Promise<string> {
const out = await (await page.request.get("/api/projects")).json();
return out.projects.find((p: { name: string }) => p.name === "wiki").id;
}
// Implementation-agnostic toast assertion: matches the legacy #toast div or
// a sonner toast, so specs pin BEHAVIOR (text shown) not markup.
export async function expectToast(page: Page, text: string | RegExp) {
const { expect } = await import("@playwright/test");
await expect(page.locator("#toast.show, [data-sonner-toast]").filter({ hasText: text }).first()).toBeVisible();
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER, PASSWORD } from "./helpers";
import { login, wikiId, MEMBER, PASSWORD, expectToast } from "./helpers";
// Phase 1: shell, session flags, project list/selection, routing, empty
// state, invite accept. Mutating specs (project creation) run last —
@@ -64,8 +64,7 @@ test("join link accepts an invite after sign-in", async ({ page, browser }) => {
await p2.fill('input[name="email"]', MEMBER);
await p2.fill('input[name="password"]', PASSWORD);
await p2.click("form button");
await p2.waitForSelector("#toast.show");
await expect(p2.locator("#toast")).toContainText("you joined");
await expectToast(p2, "you joined");
await p2.waitForURL(/\/p-[0-9a-f]{8}$/); // lands on the org's project
await ctx.close();
});
@@ -90,5 +89,5 @@ test("new project via the sidebar + modal", async ({ page }) => {
await page.waitForURL(/\/p-[0-9a-f]{8}$/);
await expect(page.locator("#project-select option:checked")).toHaveText("scratch");
await expect(page.locator("#project-select option")).toContainText(["scratch", "wiki"]);
await expect(page.locator("#toast")).toContainText("Created");
await expectToast(page, "Created");
});
+2 -3
View File
@@ -460,9 +460,8 @@ button, input, a.btn { font-family: inherit; }
.modal-input:focus { border-color: var(--accent); background: var(--hover); }
/* ---- toast ---- */
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--bg-raise); color: var(--text); border: 1px solid var(--border-2); border-radius: 10px; padding: 11px 18px; font-size: 13.5px; box-shadow: 0 18px 44px -12px rgba(0,0,0,.7); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 200; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.err { border-color: rgba(242,109,109,.5); color: #ffb0aa; }
[data-sonner-toast] { background: var(--bg-raise) !important; color: var(--text) !important; border: 1px solid var(--border-2) !important; border-radius: 10px !important; font-size: 13.5px !important; box-shadow: 0 18px 44px -12px rgba(0,0,0,.7) !important; }
[data-sonner-toast][data-type="error"] { border-color: rgba(242,109,109,.5) !important; color: #ffb0aa !important; }
#sb-backdrop { display: none; }
+8 -30
View File
@@ -1,37 +1,15 @@
import { useSyncExternalStore } from "react";
import { toast as sonner } from "sonner";
import { Toaster as SonnerToaster } from "@/components/ui/sonner";
// Transient toast, replacing blocking alert(). Imperative `toast()` from
// anywhere; <Toaster/> (mounted once in App) renders it.
type ToastState = { msg: string; err: boolean; shown: boolean };
let state: ToastState = { msg: "", err: false, shown: false };
let listeners: Array<() => void> = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function emit(next: ToastState) {
state = next;
listeners.forEach((l) => l());
}
// Transient toast. Same imperative `toast()` API as the original in-house
// version; sonner (via the shadcn wrapper) renders it. Call sites and specs
// are markup-agnostic.
export function toast(msg: string, isErr = false) {
emit({ msg, err: isErr, shown: true });
clearTimeout(timer);
timer = setTimeout(() => emit({ ...state, shown: false }), 3200);
if (isErr) sonner.error(msg);
else sonner(msg);
}
export function Toaster() {
const s = useSyncExternalStore(
(l) => {
listeners.push(l);
return () => {
listeners = listeners.filter((x) => x !== l);
};
},
() => state,
);
return (
<div id="toast" className={s.shown ? "show" + (s.err ? " err" : "") : ""}>
{s.msg}
</div>
);
return <SonnerToaster position="bottom-center" />;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-m4Vx6mmC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DMJdCyap.css">
<script type="module" crossorigin src="/assets/index-ClGU06Ni.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-53HfgqM0.css">
</head>
<body>
<div id="root"></div>