diff --git a/.gitignore b/.gitignore index 5eaaa4e7..8be59a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules coverage __screenshots__ test-results +.vitest .vitest-attachments # Turbo diff --git a/AGENTS.md b/AGENTS.md index 95e871cf..a19df670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -349,6 +349,8 @@ describe("myFunction", () => { }); ``` +Browser tests (`*.test.tsx`) use `vitest-browser-react` and locators from `vitest/browser`. Render through `@/mocks/react`, query with `page.getBy*`, interact with locator actions (`click()`, `fill()`, `hover()`), and assert with `await expect.element(...)`. Do not use Testing Library. + ## Project Structure This is a **Turborepo monorepo** with the following structure: diff --git a/apps/web/components/calendar-instructions.test.tsx b/apps/web/components/calendar-instructions.test.tsx index dc7634de..e3e94e5c 100644 --- a/apps/web/components/calendar-instructions.test.tsx +++ b/apps/web/components/calendar-instructions.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; vi.mock("@/lib/trpc/client", async () => { const { useTRPC } = await import("@/mocks/trpc"); @@ -13,7 +13,7 @@ vi.mock("sonner", () => ({ }, })); -import { createTestQueryClient, render, screen, waitFor, within } from "@/mocks/react"; +import { createTestQueryClient, render } from "@/mocks/react"; import { CALENDAR_FEED_QUERY_KEY, CALENDAR_FEED_ROTATED_URL, @@ -34,7 +34,7 @@ const enabledFeed: CalendarFeedData = { lastAccessedAt: null, }; -function renderInstructions(feed: CalendarFeedData = { enabled: false }) { +async function renderInstructions(feed: CalendarFeedData = { enabled: false }) { const queryClient = createTestQueryClient(); setCalendarFeedState(feed); queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, feed); @@ -51,42 +51,46 @@ describe("CalendarInstructions", () => { }); it("enables the feed from the empty state", async () => { - const user = userEvent.setup(); - renderInstructions(); + await renderInstructions(); - await user.click(screen.getByRole("button", { name: "Enable" })); + await page.getByRole("button", { name: "Enable" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(enableCalendarFeedMutation).toHaveBeenCalledOnce(); }); - expect(await screen.findByText("Treat this URL like a password!")).toBeInTheDocument(); - expect(screen.getByText(CALENDAR_FEED_URL)).toBeInTheDocument(); + await expect + .element(page.getByText("Treat this URL like a password!", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText(CALENDAR_FEED_URL, { exact: true })).toBeInTheDocument(); }); - it("shows the feed URL and last-accessed copy when enabled", () => { - renderInstructions({ + it("shows the feed URL and last-accessed copy when enabled", async () => { + await renderInstructions({ ...enabledFeed, lastAccessedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), }); - expect(screen.getByText("Treat this URL like a password!")).toBeInTheDocument(); - expect(screen.getByText(CALENDAR_FEED_URL)).toBeInTheDocument(); - expect(screen.getByText(/Last accessed/)).toBeInTheDocument(); - expect(screen.queryByText("Not accessed yet.")).not.toBeInTheDocument(); + await expect + .element(page.getByText("Treat this URL like a password!", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText(CALENDAR_FEED_URL, { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText(/Last accessed/)).toBeInTheDocument(); + await expect + .element(page.getByText("Not accessed yet.", { exact: true })) + .not.toBeInTheDocument(); }); - it("says the feed has not been accessed yet", () => { - renderInstructions(enabledFeed); + it("says the feed has not been accessed yet", async () => { + await renderInstructions(enabledFeed); - expect(screen.getByText("Not accessed yet.")).toBeInTheDocument(); + await expect.element(page.getByText("Not accessed yet.", { exact: true })).toBeInTheDocument(); }); it("opens calendar apps from the Open In menu", async () => { - const user = userEvent.setup(); - renderInstructions(enabledFeed); + await renderInstructions(enabledFeed); - await user.click(screen.getByRole("button", { name: /Open In/ })); - await screen.findByRole("menu"); + await page.getByRole("button", { name: /Open In/ }).click(); + await expect.element(page.getByRole("menu")).toBeInTheDocument(); const webcal = CALENDAR_FEED_URL.replace("https://", "webcal://"); expect(document.querySelector('a[href*="calendar.google.com"]')).toHaveAttribute( @@ -106,48 +110,52 @@ describe("CalendarInstructions", () => { }); it("regenerates the URL after confirming", async () => { - const user = userEvent.setup(); - renderInstructions(enabledFeed); + await renderInstructions(enabledFeed); - await user.click(screen.getByRole("button", { name: "Regenerate URL" })); - expect(screen.getByRole("heading", { name: "Regenerate Calendar URL?" })).toBeInTheDocument(); + await page.getByRole("button", { name: "Regenerate URL" }).click(); + await expect + .element(page.getByRole("heading", { name: "Regenerate Calendar URL?" })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect( - screen.queryByRole("heading", { name: "Regenerate Calendar URL?" }), - ).not.toBeInTheDocument(); + await page.getByRole("alertdialog").getByRole("button", { name: "Cancel" }).click(); + await expect + .element(page.getByRole("heading", { name: "Regenerate Calendar URL?" })) + .not.toBeInTheDocument(); expect(rotateCalendarFeedTokenMutation).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Regenerate URL" })); - await user.click(screen.getByRole("button", { name: "Regenerate" })); + await page.getByRole("button", { name: "Regenerate URL" }).click(); + await expect.element(page.getByRole("alertdialog")).toBeInTheDocument(); + await page.getByRole("alertdialog").getByRole("button", { name: "Regenerate" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(rotateCalendarFeedTokenMutation).toHaveBeenCalledOnce(); }); - expect(await screen.findByText(CALENDAR_FEED_ROTATED_URL)).toBeInTheDocument(); + await expect + .element(page.getByText(CALENDAR_FEED_ROTATED_URL, { exact: true })) + .toBeInTheDocument(); }); it("disables the feed after confirming", async () => { - const user = userEvent.setup(); - renderInstructions(enabledFeed); + await renderInstructions(enabledFeed); - await user.click(screen.getByRole("button", { name: "Disable" })); - expect(screen.getByRole("heading", { name: "Disable Calendar Feed?" })).toBeInTheDocument(); + await page.getByRole("button", { name: "Disable" }).click(); + await expect + .element(page.getByRole("heading", { name: "Disable Calendar Feed?" })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect( - screen.queryByRole("heading", { name: "Disable Calendar Feed?" }), - ).not.toBeInTheDocument(); + await page.getByRole("alertdialog").getByRole("button", { name: "Cancel" }).click(); + await expect + .element(page.getByRole("heading", { name: "Disable Calendar Feed?" })) + .not.toBeInTheDocument(); expect(deleteCalendarFeedMutation).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Disable" })); - await user.click( - within(screen.getByRole("alertdialog")).getByRole("button", { name: "Disable" }), - ); + await page.getByRole("button", { name: "Disable" }).click(); + await expect.element(page.getByRole("alertdialog")).toBeInTheDocument(); + await page.getByRole("alertdialog").getByRole("button", { name: "Disable" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(deleteCalendarFeedMutation).toHaveBeenCalledOnce(); }); - expect(await screen.findByRole("button", { name: "Enable" })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Enable" })).toBeInTheDocument(); }); }); diff --git a/apps/web/components/consent/cookie-prompt.tsx b/apps/web/components/consent/cookie-prompt.tsx index 4aa68044..dd7c2c8c 100644 --- a/apps/web/components/consent/cookie-prompt.tsx +++ b/apps/web/components/consent/cookie-prompt.tsx @@ -61,7 +61,7 @@ export function CookiePrompt({ consentRequired }: { consentRequired: boolean }) return (
diff --git a/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx b/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx index 4a19f508..c5453de4 100644 --- a/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx +++ b/apps/web/components/dashboard/add-domain/add-domain-clients.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; const nav = vi.hoisted(() => ({ push: vi.fn<(href: string, opts?: { scroll?: boolean }) => void | Promise>(), @@ -50,7 +50,7 @@ vi.mock("@/components/dashboard/add-domain/add-domain-content", () => ({ import { AddDomainModalClient } from "@/components/dashboard/add-domain/add-domain-modal-client"; import { AddDomainPageClient } from "@/components/dashboard/add-domain/add-domain-page-client"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; describe("AddDomainPageClient", () => { beforeEach(() => { @@ -64,32 +64,30 @@ describe("AddDomainPageClient", () => { }); it("parses resume params and returns to the dashboard after success", async () => { - const user = userEvent.setup(); search.params = { resume: "true", id: "domain-pending", domain: "pending.dev", method: "dns_txt", }; - render(); + await render(); - expect(JSON.parse(screen.getByTestId("resume").textContent ?? "null")).toEqual({ + expect(JSON.parse(page.getByTestId("resume").element().textContent ?? "null")).toEqual({ id: "domain-pending", domainName: "pending.dev", verificationToken: "", verificationMethod: "dns_txt", }); - expect(screen.getByTestId("prefill")).toHaveTextContent("from-report.com"); - expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + await expect.element(page.getByTestId("prefill")).toHaveTextContent("from-report.com"); + await expect.element(page.getByRole("button", { name: "Close" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Finish" })); + await page.getByRole("button", { name: "Finish" }).click(); expect(nav.push).toHaveBeenCalledWith("/dashboard", { scroll: false }); expect(nav.back).not.toHaveBeenCalled(); }); it("keeps the success action pending until dashboard navigation completes", async () => { - const user = userEvent.setup(); let finishNavigation: (() => void) | undefined; nav.push.mockImplementation( () => @@ -98,23 +96,21 @@ describe("AddDomainPageClient", () => { }), ); - render(); + await render(); - await user.click(screen.getByRole("button", { name: "Finish" })); + await page.getByRole("button", { name: "Finish" }).click(); expect(nav.push).toHaveBeenCalledWith("/dashboard", { scroll: false }); - await waitFor(() => { - expect(screen.getByRole("button", { name: /loading/i })).toBeDisabled(); - }); + await expect.element(page.getByRole("button", { name: /loading/i })).toBeDisabled(); finishNavigation?.(); - await waitFor(() => expect(screen.getByRole("button", { name: "Finish" })).toBeEnabled()); + await expect.element(page.getByRole("button", { name: "Finish" })).toBeEnabled(); }); - it("starts a fresh add when resume params are incomplete", () => { + it("starts a fresh add when resume params are incomplete", async () => { search.params = { resume: "true", domain: "pending.dev" }; - render(); - expect(screen.getByTestId("resume")).toHaveTextContent("null"); + await render(); + await expect.element(page.getByTestId("resume")).toHaveTextContent("null"); }); }); @@ -130,20 +126,18 @@ describe("AddDomainModalClient", () => { }); it("goes back after success", async () => { - const user = userEvent.setup(); - render(); + await render(); - await user.click(screen.getByRole("button", { name: "Finish" })); + await page.getByRole("button", { name: "Finish" }).click(); expect(nav.back).toHaveBeenCalledOnce(); expect(nav.push).not.toHaveBeenCalled(); }); it("goes back when the modal is closed", async () => { - const user = userEvent.setup(); - render(); + await render(); - await user.click(screen.getByRole("button", { name: "Close" })); + await page.getByRole("button", { name: "Close" }).click(); expect(nav.back).toHaveBeenCalledOnce(); expect(nav.push).not.toHaveBeenCalled(); diff --git a/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx b/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx index 1543dadd..4ad22507 100644 --- a/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx +++ b/apps/web/components/dashboard/add-domain/add-domain-content.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; vi.mock("@/hooks/use-subscription", async () => { const { useSubscription } = await import("../mocks/subscription"); @@ -24,7 +24,6 @@ vi.mock("sonner", () => ({ import { AddDomainContent } from "@/components/dashboard/add-domain/add-domain-content"; import { makeResumeDomain } from "@/components/dashboard/test-fixtures"; import { DOMAIN_VALIDATION_ERROR } from "@/hooks/use-domain-verification"; -import { screen, waitFor } from "@/mocks/react"; import { addDomainActionSpies, @@ -37,9 +36,7 @@ import { } from "./test-utils"; async function waitForStep2() { - await waitFor(() => { - expect(screen.getByRole("button", { name: "Check Now" })).toBeInTheDocument(); - }); + await expect.element(page.getByRole("button", { name: "Check Now" })).toBeInTheDocument(); } describe("AddDomainContent", () => { @@ -52,122 +49,149 @@ describe("AddDomainContent", () => { }); it("adds a domain, shows DNS instructions, and calls onSuccess after verify", async () => { - const user = userEvent.setup(); - renderAddDomainContent(); + await renderAddDomainContent(); - expect(screen.getByRole("heading", { name: "Add Domain" })).toBeInTheDocument(); + await expect.element(page.getByRole("heading", { name: "Add Domain" })).toBeInTheDocument(); expect(getVerificationDataQuery).not.toHaveBeenCalled(); - await user.type(screen.getByLabelText("Domain name"), "newdomain.com"); - await user.click(screen.getByRole("button", { name: "Continue" })); + await page.getByLabelText("Domain name").fill("newdomain.com"); + await page.getByRole("button", { name: "Continue" }).click(); await waitForStep2(); expect(addDomainMutation.mock.calls[0]?.[0]).toEqual({ domain: "newdomain.com" }); - expect(screen.getByText("Recommended: Add a DNS record")).toBeInTheDocument(); - expect(screen.getByText("domainstack-verify=token-new")).toBeInTheDocument(); + await expect + .element(page.getByText("Recommended: Add a DNS record", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText("domainstack-verify=token-new", { exact: true })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Check Now" })); + await page.getByRole("button", { name: "Check Now" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(addDomainActionSpies.onSuccess).toHaveBeenCalledOnce(); }); expect(verifyDomainMutation.mock.calls[0]?.[0]).toEqual({ trackedDomainId: "domain-new" }); - expect(screen.getByRole("heading", { name: "Domain verified!" })).toBeInTheDocument(); - expect(screen.getByText("newdomain.com")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Domain verified!" })) + .toBeInTheDocument(); + await expect.element(page.getByText("newdomain.com", { exact: true })).toBeInTheDocument(); }); - it("shows the quota gate when the user cannot add more domains", () => { + it("shows the quota gate when the user cannot add more domains", async () => { mockSubscription.canAddMore = false; mockSubscription.planQuota = 5; - renderAddDomainContent(); + await renderAddDomainContent(); - expect(screen.getByRole("heading", { name: "Domain Limit Reached" })).toBeInTheDocument(); - expect(screen.getByText(/You've reached your limit of 5 tracked domains/)).toBeInTheDocument(); - expect(screen.queryByLabelText("Domain name")).not.toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Domain Limit Reached" })) + .toBeInTheDocument(); + await expect + .element(page.getByText(/You've reached your limit of 5 tracked domains/)) + .toBeInTheDocument(); + await expect.element(page.getByLabelText("Domain name")).not.toBeInTheDocument(); }); it("resumes verification on step 2 for a pending domain", async () => { - renderAddDomainContent({ resumeDomain: makeResumeDomain() }); + await renderAddDomainContent({ resumeDomain: makeResumeDomain() }); await waitForStep2(); - expect(screen.getByRole("heading", { name: "Complete Verification" })).toBeInTheDocument(); - expect(screen.getByText("Verify ownership of pending.dev")).toBeInTheDocument(); - expect(screen.getByText("Recommended: Add a DNS record")).toBeInTheDocument(); - expect(screen.getByText("domainstack-verify=token-pending")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Complete Verification" })) + .toBeInTheDocument(); + await expect + .element(page.getByText("Verify ownership of pending.dev", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText("Recommended: Add a DNS record", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText("domainstack-verify=token-pending", { exact: true })) + .toBeInTheDocument(); expect(addDomainMutation).not.toHaveBeenCalled(); expect(getVerificationDataQuery).not.toHaveBeenCalled(); }); it("fetches verification data when resuming without a token", async () => { - renderAddDomainContent({ resumeDomain: makeResumeDomain({ verificationToken: "" }) }); + await renderAddDomainContent({ resumeDomain: makeResumeDomain({ verificationToken: "" }) }); await waitForStep2(); - await waitFor(() => { + await vi.waitFor(() => { expect(getVerificationDataQuery).toHaveBeenCalledWith({ trackedDomainId: "domain-pending" }); }); - expect(screen.getByText("domainstack-verify=token-pending")).toBeInTheDocument(); + await expect + .element(page.getByText("domainstack-verify=token-pending", { exact: true })) + .toBeInTheDocument(); expect(addDomainMutation).not.toHaveBeenCalled(); }); it("stays on step 2 and shows troubleshooting when verification fails", async () => { - const user = userEvent.setup(); verifyDomainMutation.mockResolvedValueOnce({ verified: false, method: null }); - renderAddDomainContent({ resumeDomain: makeResumeDomain() }); + await renderAddDomainContent({ resumeDomain: makeResumeDomain() }); await waitForStep2(); - await user.click(screen.getByRole("button", { name: "Check Now" })); + await page.getByRole("button", { name: "Check Now" }).click(); - await waitFor(() => { - expect(screen.getByText("Verification Failed")).toBeInTheDocument(); - }); - expect(screen.getByText("DNS Record Troubleshooting")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Check Again" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Complete Verification" })).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Domain verified!" })).not.toBeInTheDocument(); + await expect + .element(page.getByText("Verification Failed", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText("DNS Record Troubleshooting", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Check Again" })).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Complete Verification" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Domain verified!" })) + .not.toBeInTheDocument(); expect(addDomainActionSpies.onSuccess).not.toHaveBeenCalled(); }); it("normalizes the domain before adding it", async () => { - const user = userEvent.setup(); - renderAddDomainContent(); + await renderAddDomainContent(); - await user.type(screen.getByLabelText("Domain name"), "HTTPS://www.Example.COM/path"); - await user.click(screen.getByRole("button", { name: "Continue" })); + await page.getByLabelText("Domain name").fill("HTTPS://www.Example.COM/path"); + await page.getByRole("button", { name: "Continue" }).click(); await waitForStep2(); expect(addDomainMutation.mock.calls[0]?.[0]).toEqual({ domain: "example.com" }); - expect(screen.getByText("domainstack-verify=token-new")).toBeInTheDocument(); + await expect + .element(page.getByText("domainstack-verify=token-new", { exact: true })) + .toBeInTheDocument(); }); it("shows an inline error for an invalid domain", async () => { - const user = userEvent.setup(); - renderAddDomainContent(); + await renderAddDomainContent(); - await user.type(screen.getByLabelText("Domain name"), "not a domain"); - await user.click(screen.getByRole("button", { name: "Continue" })); + await page.getByLabelText("Domain name").fill("not a domain"); + await page.getByRole("button", { name: "Continue" }).click(); - expect(screen.getByText(DOMAIN_VALIDATION_ERROR)).toBeInTheDocument(); + await expect + .element(page.getByText(DOMAIN_VALIDATION_ERROR, { exact: true })) + .toBeInTheDocument(); expect(addDomainMutation).not.toHaveBeenCalled(); - expect(screen.queryByRole("button", { name: "Check Now" })).not.toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Check Now" })).not.toBeInTheDocument(); }); it("shows the same inline error when Enter is pressed on an invalid domain", async () => { - const user = userEvent.setup(); - renderAddDomainContent(); + await renderAddDomainContent(); - const input = screen.getByLabelText("Domain name"); - await user.type(input, "not a domain{Enter}"); + const input = page.getByLabelText("Domain name"); + await userEvent.type(input, "not a domain{Enter}"); - expect(screen.getByText(DOMAIN_VALIDATION_ERROR)).toBeInTheDocument(); + await expect + .element(page.getByText(DOMAIN_VALIDATION_ERROR, { exact: true })) + .toBeInTheDocument(); expect(addDomainMutation).not.toHaveBeenCalled(); }); it("remounts to step 1 when resume identity is replaced by a prefill", async () => { - const { rerender } = renderAddDomainContent({ resumeDomain: makeResumeDomain() }); + const { rerender } = await renderAddDomainContent({ resumeDomain: makeResumeDomain() }); await waitForStep2(); - rerender( + await rerender( { />, ); - expect(screen.getByRole("heading", { name: "Add Domain" })).toBeInTheDocument(); - expect(screen.getByLabelText("Domain name")).toHaveValue("fresh.com"); - expect(screen.queryByRole("button", { name: "Check Now" })).not.toBeInTheDocument(); + await expect.element(page.getByRole("heading", { name: "Add Domain" })).toBeInTheDocument(); + await expect.element(page.getByLabelText("Domain name")).toHaveValue("fresh.com"); + await expect.element(page.getByRole("button", { name: "Check Now" })).not.toBeInTheDocument(); }); }); diff --git a/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx b/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx index 214afe06..061c1ba5 100644 --- a/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx +++ b/apps/web/components/dashboard/add-domain/share-instructions-dialog.test.tsx @@ -1,6 +1,6 @@ -import userEvent from "@testing-library/user-event"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; vi.mock("@/lib/trpc/client", async () => { const { useTRPC } = await import("@/mocks/trpc"); @@ -15,25 +15,25 @@ vi.mock("sonner", () => ({ })); import { ShareInstructionsDialog } from "@/components/dashboard/add-domain/share-instructions-dialog"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { resetTrpcMocks, sendVerificationInstructionsMutation } from "@/mocks/trpc"; const DOMAIN = "pending.dev"; const TOKEN = "token-pending"; const TRACKED_ID = "domain-pending"; -async function openShareDialog(user: ReturnType) { - render( +async function openShareDialog() { + await render( , ); - await user.click(screen.getByRole("button", { name: "Share" })); - expect( - await screen.findByRole("heading", { name: "Share Verification Instructions" }), - ).toBeInTheDocument(); + await page.getByRole("button", { name: "Share" }).click(); + await expect + .element(page.getByRole("heading", { name: "Share Verification Instructions" })) + .toBeInTheDocument(); } describe("ShareInstructionsDialog", () => { @@ -48,26 +48,26 @@ describe("ShareInstructionsDialog", () => { }); it("opens the three share options", async () => { - const user = userEvent.setup(); - await openShareDialog(user); + await openShareDialog(); - expect(screen.getByText("Copy to Clipboard")).toBeInTheDocument(); - expect(screen.getByText("Download as File")).toBeInTheDocument(); - expect(screen.getByText("Send via Email")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument(); - expect(screen.getByPlaceholderText(`admin@${DOMAIN}\u2026`)).toBeInTheDocument(); + await expect.element(page.getByText("Copy to Clipboard", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Download as File", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Send via Email", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Copy to clipboard" })) + .toBeInTheDocument(); + await expect.element(page.getByPlaceholder(`admin@${DOMAIN}\u2026`)).toBeInTheDocument(); }); it("downloads instructions as a text file", async () => { - const user = userEvent.setup(); const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); const click = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => undefined); - await openShareDialog(user); - await user.click(screen.getByRole("button", { name: "Download instructions" })); + await openShareDialog(); + await page.getByRole("button", { name: "Download instructions" }).click(); expect(createObjectURL).toHaveBeenCalledOnce(); expect(click).toHaveBeenCalledOnce(); @@ -81,33 +81,31 @@ describe("ShareInstructionsDialog", () => { }); it("keeps Send enabled and shows an inline error for invalid email", async () => { - const user = userEvent.setup(); - await openShareDialog(user); + await openShareDialog(); - const send = screen.getByRole("button", { name: "Send email" }); - expect(send).toBeEnabled(); + const send = page.getByRole("button", { name: "Send email" }); + await expect.element(send).toBeEnabled(); - await user.click(send); - expect(screen.getByText(/Enter an email address/)).toBeInTheDocument(); + await send.click(); + await expect.element(page.getByText(/Enter an email address/)).toBeInTheDocument(); - await user.type(screen.getByLabelText("Email address"), "not-an-email"); - await user.click(send); - expect(screen.getByText(/Enter a valid email address/)).toBeInTheDocument(); + await page.getByLabelText("Email address").fill("not-an-email"); + await send.click(); + await expect.element(page.getByText(/Enter a valid email address/)).toBeInTheDocument(); expect(sendVerificationInstructionsMutation).not.toHaveBeenCalled(); - await user.clear(screen.getByLabelText("Email address")); - await user.type(screen.getByLabelText("Email address"), "admin@pending.dev"); - expect(send).toBeEnabled(); + await page.getByLabelText("Email address").clear(); + await page.getByLabelText("Email address").fill("admin@pending.dev"); + await expect.element(send).toBeEnabled(); }); it("sends instructions to a trimmed email address", async () => { - const user = userEvent.setup(); - await openShareDialog(user); + await openShareDialog(); - await user.type(screen.getByLabelText("Email address"), " admin@pending.dev "); - await user.click(screen.getByRole("button", { name: "Send email" })); + await page.getByLabelText("Email address").fill(" admin@pending.dev "); + await page.getByRole("button", { name: "Send email" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(sendVerificationInstructionsMutation.mock.calls[0]?.[0]).toEqual({ trackedDomainId: TRACKED_ID, recipientEmail: "admin@pending.dev", @@ -119,25 +117,24 @@ describe("ShareInstructionsDialog", () => { }); it("toasts an error when sending fails so the user can retry", async () => { - const user = userEvent.setup(); sendVerificationInstructionsMutation.mockRejectedValueOnce(new Error("nope")); - await openShareDialog(user); + await openShareDialog(); - await user.type(screen.getByLabelText("Email address"), "admin@pending.dev"); - await user.click(screen.getByRole("button", { name: "Send email" })); + await page.getByLabelText("Email address").fill("admin@pending.dev"); + await page.getByRole("button", { name: "Send email" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to send email", { description: "Please try again or use another method.", }); }); - const email = screen.getByLabelText("Email address"); - expect(email).toHaveValue("admin@pending.dev"); - expect(screen.getByRole("button", { name: "Send email" })).toBeEnabled(); - await user.click(screen.getByRole("button", { name: "Send email" })); + const email = page.getByLabelText("Email address"); + await expect.element(email).toHaveValue("admin@pending.dev"); + await expect.element(page.getByRole("button", { name: "Send email" })).toBeEnabled(); + await page.getByRole("button", { name: "Send email" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(sendVerificationInstructionsMutation).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/components/dashboard/add-domain/test-utils.tsx b/apps/web/components/dashboard/add-domain/test-utils.tsx index 9d29967b..f54e373c 100644 --- a/apps/web/components/dashboard/add-domain/test-utils.tsx +++ b/apps/web/components/dashboard/add-domain/test-utils.tsx @@ -34,7 +34,7 @@ export type RenderAddDomainContentOptions = { onClose?: () => void; }; -export function renderAddDomainContent(options: RenderAddDomainContentOptions = {}) { +export async function renderAddDomainContent(options: RenderAddDomainContentOptions = {}) { return render( { const { useSubscription } = await import("./mocks/subscription"); @@ -33,7 +33,6 @@ import { renderArchivedList, resetDashboardTestState, } from "@/components/dashboard/test-utils"; -import { screen } from "@/mocks/react"; import { PLAN_QUOTAS } from "@domainstack/constants"; const archived = makeTrackedDomain({ @@ -52,40 +51,43 @@ describe("ArchivedDomainsList", () => { vi.useRealTimers(); }); - it("shows an empty state", () => { - renderArchivedList([]); - expect(screen.getByText("No archived domains")).toBeInTheDocument(); + it("shows an empty state", async () => { + await renderArchivedList([]); + await expect + .element(page.getByText("No archived domains", { exact: true })) + .toBeInTheDocument(); }); it("reactivates and deletes an archived domain", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - renderArchivedList([archived]); + await renderArchivedList([archived]); - expect(screen.getByText("archived.com")).toBeInTheDocument(); + await expect.element(page.getByText("archived.com", { exact: true })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /Reactivate/ })); + await page.getByRole("button", { name: /Reactivate/ }).click(); expect(dashboardActionSpies.onUnarchive).toHaveBeenCalledWith("domain-archived"); - await user.click(screen.getByRole("button", { name: "Delete" })); + await page.getByRole("button", { name: "Delete" }).click(); expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-archived"); }); it("blocks reactivate and shows an upgrade banner on Free at the limit", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; mockSubscription.canAddMore = false; - renderArchivedList([archived]); + await renderArchivedList([archived]); - expect(screen.getByText("Upgrade to Reactivate")).toBeInTheDocument(); - expect(screen.getByText(/You've reached your domain tracking limit/)).toBeInTheDocument(); + await expect + .element(page.getByText("Upgrade to Reactivate", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText(/You've reached your domain tracking limit/)) + .toBeInTheDocument(); - const reactivate = screen.getByRole("button", { name: /Reactivate/ }); - expect(reactivate).toBeDisabled(); - await user.click(reactivate); + const reactivate = page.getByRole("button", { name: /Reactivate/ }); + await expect.element(reactivate).toBeDisabled(); expect(dashboardActionSpies.onUnarchive).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Delete" })); + await page.getByRole("button", { name: "Delete" }).click(); expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-archived"); }); @@ -93,9 +95,11 @@ describe("ArchivedDomainsList", () => { mockSubscription.plan = "pro"; mockSubscription.planQuota = PLAN_QUOTAS.pro; mockSubscription.canAddMore = false; - renderArchivedList([archived]); + await renderArchivedList([archived]); - expect(screen.queryByText("Upgrade to Reactivate")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Reactivate/ })).toBeDisabled(); + await expect + .element(page.getByText("Upgrade to Reactivate", { exact: true })) + .not.toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /Reactivate/ })).toBeDisabled(); }); }); diff --git a/apps/web/components/dashboard/bulk-actions-toolbar.tsx b/apps/web/components/dashboard/bulk-actions-toolbar.tsx index 56228c95..a33c7f70 100644 --- a/apps/web/components/dashboard/bulk-actions-toolbar.tsx +++ b/apps/web/components/dashboard/bulk-actions-toolbar.tsx @@ -62,7 +62,7 @@ export function BulkActionsToolbar({ totalCount, className }: BulkActionsToolbar return (
{ const { useTRPC } = await import("@/mocks/trpc"); @@ -13,7 +13,7 @@ vi.mock("sonner", () => ({ }, })); -import { createTestQueryClient, render, screen } from "@/mocks/react"; +import { createTestQueryClient, render } from "@/mocks/react"; import { CALENDAR_FEED_QUERY_KEY, resetTrpcMocks, setCalendarFeedState } from "@/mocks/trpc"; import { CalendarFeedPopover } from "./calendar-feed-popover"; @@ -28,19 +28,22 @@ describe("CalendarFeedPopover", () => { }); it("opens the calendar feed instructions", async () => { - const user = userEvent.setup(); const queryClient = createTestQueryClient(); setCalendarFeedState({ enabled: false }); queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, { enabled: false }); - render(, { queryClient }); + await render(, { queryClient }); - await user.click(screen.getByRole("button", { name: "Subscribe" })); + await page.getByRole("button", { name: "Subscribe" }).click(); - expect(screen.getByRole("heading", { name: "Calendar Feed" })).toBeInTheDocument(); - expect( - screen.getByText("Subscribe to domain expiration dates in your favorite calendar app"), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument(); + await expect.element(page.getByRole("heading", { name: "Calendar Feed" })).toBeInTheDocument(); + await expect + .element( + page.getByText("Subscribe to domain expiration dates in your favorite calendar app", { + exact: true, + }), + ) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Enable" })).toBeInTheDocument(); }); }); diff --git a/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx b/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx index f96f3a11..98d9d1ac 100644 --- a/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx +++ b/apps/web/components/dashboard/dashboard-banner-dismissable.test.tsx @@ -1,15 +1,14 @@ -import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; describe("DashboardBannerDismissable", () => { it("forwards onDismiss when the banner is dismissed", async () => { - const user = userEvent.setup(); const onDismiss = vi.fn<() => void>(); - render( + await render( { />, ); - const banner = screen.getByText("Welcome to Pro!").closest("[data-slot=card]"); - expect(banner).toBeTruthy(); - await user.hover(banner!); - await user.click(screen.getByRole("button", { name: "Dismiss" })); + await expect.element(page.getByText("Welcome to Pro!", { exact: true })).toBeVisible(); - await waitFor(() => { - expect(screen.queryByText("Welcome to Pro!")).not.toBeInTheDocument(); - }); + const banner = page + .getByText("Welcome to Pro!", { exact: true }) + .element() + .closest("[data-slot=card]"); + expect(banner).toBeTruthy(); + await userEvent.hover(banner!); + await page.getByRole("button", { name: "Dismiss" }).click(); + + await expect + .element(page.getByText("Welcome to Pro!", { exact: true })) + .not.toBeInTheDocument(); expect(onDismiss).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx b/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx index df6f13b9..638fa3dc 100644 --- a/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx +++ b/apps/web/components/dashboard/dashboard-confirm-dialog.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; vi.mock("@/hooks/use-subscription", async () => { const { useSubscription } = await import("./mocks/subscription"); @@ -31,28 +31,36 @@ import { renderDashboardConfirmShell, resetDashboardTestState, } from "@/components/dashboard/test-utils"; -import { screen, waitFor, within } from "@/mocks/react"; async function waitForCatalog() { - await waitFor(() => { - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - }); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); } function domainCard(name: string) { - const card = screen.getByRole("link", { name }).closest(".group"); + const card = page.getByRole("link", { name }).element().closest(".group"); expect(card).not.toBeNull(); return card as HTMLElement; } -async function selectGridCard(user: ReturnType, name: string) { - await user.hover(domainCard(name)); - await user.click(screen.getByRole("checkbox", { name: `Select ${name}` })); +function cardButton(card: HTMLElement, name: string) { + const button = Array.from(card.querySelectorAll("button")).find((btn) => + btn.textContent?.includes(name), + ); + expect(button).toBeTruthy(); + return button!; +} + +async function selectGridCard(name: string) { + const card = domainCard(name); + await userEvent.hover(card); + await expect.element(page.getByRole("checkbox", { name: `Select ${name}` })).toBeInTheDocument(); + await page.getByRole("checkbox", { name: `Select ${name}` }).click(); } describe("dashboard confirm dialog", () => { - beforeEach(() => { + beforeEach(async () => { resetDashboardTestState(); + await userEvent.unhover(document.body); }); afterEach(() => { @@ -61,58 +69,58 @@ describe("dashboard confirm dialog", () => { }); it("archives a card after confirming the dialog", async () => { - const user = userEvent.setup(); - renderDashboardConfirmShell(); + await renderDashboardConfirmShell(); await waitForCatalog(); const card = domainCard("alpha.com"); - await user.hover(card); - await user.click(within(card).getByRole("button", { name: "Actions" })); - await user.click(await screen.findByRole("menuitem", { name: "Archive" })); + await userEvent.hover(card); + await userEvent.click(cardButton(card, "Actions")); + await page.getByRole("menuitem", { name: "Archive" }).click(); - const dialog = await screen.findByRole("alertdialog"); - expect(within(dialog).getByRole("heading", { name: "Archive domain?" })).toBeInTheDocument(); + await expect.element(page.getByRole("alertdialog")).toBeInTheDocument(); + await expect + .element(page.getByRole("alertdialog").getByRole("heading", { name: "Archive domain?" })) + .toBeInTheDocument(); expect(dashboardActionSpies.onArchive).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole("button", { name: "Archive" })); + await page.getByRole("alertdialog").getByRole("button", { name: "Archive" }).click(); expect(dashboardActionSpies.onArchive).toHaveBeenCalledWith("domain-alpha"); }); it("does not archive when the dialog is cancelled", async () => { - const user = userEvent.setup(); - renderDashboardConfirmShell(); + await renderDashboardConfirmShell(); await waitForCatalog(); const card = domainCard("alpha.com"); - await user.hover(card); - await user.click(within(card).getByRole("button", { name: "Actions" })); - await user.click(await screen.findByRole("menuitem", { name: "Archive" })); + await userEvent.hover(card); + await userEvent.click(cardButton(card, "Actions")); + await page.getByRole("menuitem", { name: "Archive" }).click(); - const dialog = await screen.findByRole("alertdialog"); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + await expect.element(page.getByRole("alertdialog")).toBeInTheDocument(); + await page.getByRole("alertdialog").getByRole("button", { name: "Cancel" }).click(); - await waitFor(() => { - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); - }); + await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument(); expect(dashboardActionSpies.onArchive).not.toHaveBeenCalled(); }); it("bulk-deletes after confirming the dialog", async () => { - const user = userEvent.setup(); - renderDashboardConfirmShell(); + await renderDashboardConfirmShell(); await waitForCatalog(); - await selectGridCard(user, "alpha.com"); - await selectGridCard(user, "beta.io"); + await selectGridCard("alpha.com"); + await selectGridCard("beta.io"); - const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" }); - await user.click(within(toolbar).getByRole("button", { name: "Delete" })); + const toolbar = page.getByRole("toolbar", { name: "Bulk actions" }); + await expect.element(toolbar).toBeInTheDocument(); + await toolbar.getByRole("button", { name: "Delete" }).click(); - const dialog = await screen.findByRole("alertdialog"); - expect(within(dialog).getByRole("heading", { name: "Delete 2 domains?" })).toBeInTheDocument(); + await expect.element(page.getByRole("alertdialog")).toBeInTheDocument(); + await expect + .element(page.getByRole("alertdialog").getByRole("heading", { name: "Delete 2 domains?" })) + .toBeInTheDocument(); expect(dashboardActionSpies.onBulkDelete).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole("button", { name: "Delete All" })); + await page.getByRole("alertdialog").getByRole("button", { name: "Delete All" }).click(); expect(dashboardActionSpies.onBulkDelete).toHaveBeenCalledWith(["domain-alpha", "domain-beta"]); }); }); diff --git a/apps/web/components/dashboard/dashboard-quota.test.tsx b/apps/web/components/dashboard/dashboard-quota.test.tsx index a2ea7796..16f14dd8 100644 --- a/apps/web/components/dashboard/dashboard-quota.test.tsx +++ b/apps/web/components/dashboard/dashboard-quota.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; vi.mock("@/hooks/use-subscription", async () => { const { useSubscription } = await import("./mocks/subscription"); @@ -35,13 +35,11 @@ import { subscriptionActionSpies, } from "@/components/dashboard/test-utils"; import { UpgradeBanner } from "@/components/dashboard/upgrade-banner"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { PLAN_QUOTAS } from "@domainstack/constants"; async function waitForCatalog() { - await waitFor(() => { - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - }); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); } describe("dashboard quota and banners", () => { @@ -56,153 +54,164 @@ describe("dashboard quota and banners", () => { describe("header", () => { it("shows the Pro badge, quota meter, and Add Domain link", async () => { - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - expect(screen.getByText("Pro")).toBeInTheDocument(); - expect(screen.getByRole("meter", { name: "Domain usage" })).toHaveAttribute( - "aria-valuetext", - "4 of 100 domains used", - ); - expect(screen.getByText("4/100")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Add Domain" })).toHaveAttribute( - "href", - "/dashboard/add-domain", - ); + await expect.element(page.getByText("Pro", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("meter", { name: "Domain usage" })) + .toHaveAttribute("aria-valuetext", "4 of 100 domains used"); + await expect.element(page.getByText("4/100", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "Add Domain" })) + .toHaveAttribute("href", "/dashboard/add-domain"); }); it("shows a Free badge", async () => { mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - expect(screen.getByText("Free")).toBeInTheDocument(); - expect(screen.queryByText("Pro")).not.toBeInTheDocument(); + await expect.element(page.getByText("Free", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Pro", { exact: true })).not.toBeInTheDocument(); }); it("disables Add Domain at the Pro limit", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.canAddMore = false; - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - const addDomain = screen.getByRole("button", { name: "Add Domain" }); - expect(addDomain).toBeDisabled(); - expect(screen.queryByRole("link", { name: "Add Domain" })).not.toBeInTheDocument(); + const addDomain = page.getByRole("button", { name: "Add Domain" }); + await expect.element(addDomain).toBeDisabled(); + await expect.element(page.getByRole("link", { name: "Add Domain" })).not.toBeInTheDocument(); - await user.hover(addDomain); - expect(await screen.findByText("Domain limit reached")).toBeInTheDocument(); + await addDomain.hover(); + await expect + .element(page.getByText("Domain limit reached", { exact: true })) + .toBeInTheDocument(); }); it("offers checkout from the at-limit tooltip on Free", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; mockSubscription.canAddMore = false; - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.hover(screen.getByRole("button", { name: "Add Domain" })); - const upgrade = await screen.findByRole("button", { name: /Upgrade to add more domains/ }); - await user.click(upgrade); + await page.getByRole("button", { name: "Add Domain" }).hover(); + const upgrade = page.getByRole("button", { name: /Upgrade to add more domains/ }); + await expect.element(upgrade).toBeInTheDocument(); + await upgrade.click(); expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce(); }); it("shows access-until copy on a canceling Pro badge", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.endsAt = daysFromTestNow(10); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.hover(screen.getByText("Pro")); - expect(await screen.findByText("Access until Sep 2, 2026")).toBeInTheDocument(); + await page.getByText("Pro", { exact: true }).hover(); + await expect + .element(page.getByText("Access until Sep 2, 2026", { exact: true })) + .toBeInTheDocument(); }); }); describe("UpgradeBanner", () => { - it("hides for Pro and for Free users under 80%", () => { - render(); - expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument(); - expect(screen.queryByText("Domain Limit Reached")).not.toBeInTheDocument(); + it("hides for Pro and for Free users under 80%", async () => { + await render(); + await expect + .element(page.getByText("Approaching Limit", { exact: true })) + .not.toBeInTheDocument(); + await expect + .element(page.getByText("Domain Limit Reached", { exact: true })) + .not.toBeInTheDocument(); mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; mockSubscription.activeCount = 3; mockSubscription.canAddMore = true; - render(); - expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument(); + await render(); + await expect + .element(page.getByText("Approaching Limit", { exact: true })) + .not.toBeInTheDocument(); }); it("warns when Free is near the limit and can be dismissed", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; mockSubscription.activeCount = 4; mockSubscription.canAddMore = true; - render(); + await render(); - expect(screen.getByText("Approaching Limit")).toBeInTheDocument(); - expect(screen.getByText(/You're using 4 of 5 domain slots/)).toBeInTheDocument(); + await expect.element(page.getByText("Approaching Limit", { exact: true })).toBeVisible(); + await expect.element(page.getByText(/You're using 4 of 5 domain slots/)).toBeInTheDocument(); - await user.hover(screen.getByText("Approaching Limit")); - await user.click(screen.getByRole("button", { name: "Dismiss" })); - await waitFor(() => { - expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument(); - }); + await page.getByText("Approaching Limit", { exact: true }).hover(); + await page.getByRole("button", { name: "Dismiss" }).click(); + await expect + .element(page.getByText("Approaching Limit", { exact: true })) + .not.toBeInTheDocument(); }); it("shows the at-limit banner and starts checkout", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.plan = "free"; mockSubscription.planQuota = PLAN_QUOTAS.free; mockSubscription.activeCount = 5; mockSubscription.canAddMore = false; - render(); + await render(); - expect(screen.getByText("Domain Limit Reached")).toBeInTheDocument(); - expect( - screen.getByText(/You've reached your limit of 5 tracked domains/), - ).toBeInTheDocument(); + await expect + .element(page.getByText("Domain Limit Reached", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText(/You've reached your limit of 5 tracked domains/)) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Upgrade" })); + await page.getByRole("button", { name: "Upgrade" }).click(); expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce(); }); }); describe("SubscriptionEndingBanner", () => { - it("hides without an end date or when already expired", () => { - render(); - expect(screen.queryByText("Your Pro subscription is ending")).not.toBeInTheDocument(); + it("hides without an end date or when already expired", async () => { + await render(); + await expect + .element(page.getByText("Your Pro subscription is ending", { exact: true })) + .not.toBeInTheDocument(); mockSubscription.endsAt = daysFromTestNow(-1); - render(); - expect(screen.queryByText(/Pro subscription ending/)).not.toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/Pro subscription ending/)).not.toBeInTheDocument(); }); it("shows resubscribe actions when Pro is ending later", async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); mockSubscription.endsAt = daysFromTestNow(10); - render(); + await render(); - expect(screen.getByText("Your Pro subscription is ending")).toBeInTheDocument(); - expect(screen.getByText("Sep 2, 2026")).toBeInTheDocument(); - expect( - screen.getByText(new RegExp(`free quota of ${PLAN_QUOTAS.free} domains`)), - ).toBeInTheDocument(); + await expect + .element(page.getByText("Your Pro subscription is ending", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText("Sep 2, 2026", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByText(new RegExp(`free quota of ${PLAN_QUOTAS.free} domains`))) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Resubscribe" })); + await page.getByRole("button", { name: "Resubscribe" }).click(); expect(subscriptionActionSpies.handleCheckout).toHaveBeenCalledOnce(); - await user.click(screen.getByRole("button", { name: "Manage" })); + await page.getByRole("button", { name: "Manage" }).click(); expect(subscriptionActionSpies.handleCustomerPortal).toHaveBeenCalledOnce(); }); - it("uses urgent copy when Pro ends within three days", () => { + it("uses urgent copy when Pro ends within three days", async () => { mockSubscription.endsAt = daysFromTestNow(2); - render(); + await render(); - expect(screen.getByText("Pro subscription ending in 2 days")).toBeInTheDocument(); + await expect + .element(page.getByText("Pro subscription ending in 2 days", { exact: true })) + .toBeInTheDocument(); }); }); }); diff --git a/apps/web/components/dashboard/dashboard-shell.test.tsx b/apps/web/components/dashboard/dashboard-shell.test.tsx index b0eef1d5..52a8109a 100644 --- a/apps/web/components/dashboard/dashboard-shell.test.tsx +++ b/apps/web/components/dashboard/dashboard-shell.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; vi.mock("@/hooks/use-subscription", async () => { const { useSubscription } = await import("./mocks/subscription"); @@ -33,39 +33,48 @@ import { resetDashboardTestState, } from "@/components/dashboard/test-utils"; import { usePreferencesStore } from "@/lib/stores/preferences-store"; -import { screen, waitFor, within } from "@/mocks/react"; function domainNames() { - return screen - .queryAllByRole("link") + return page + .getByRole("link") + .elements() .map((el) => el.textContent?.replace(/\s+/g, " ").trim() ?? "") .filter((name) => name.includes(".")); } function getFilterTrigger(name: RegExp) { - return screen.getAllByRole("combobox", { name })[0]; + return page.getByRole("combobox", { name }).nth(0); } async function waitForCatalog() { - await waitFor(() => { - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - }); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); } function domainCard(name: string) { - const card = screen.getByRole("link", { name }).closest(".group"); + const card = page.getByRole("link", { name }).element().closest(".group"); expect(card).not.toBeNull(); return card as HTMLElement; } -async function selectGridCard(user: ReturnType, name: string) { - await user.hover(domainCard(name)); - await user.click(screen.getByRole("checkbox", { name: `Select ${name}` })); +function cardButton(card: HTMLElement, name: string) { + const button = Array.from(card.querySelectorAll("button")).find((btn) => + btn.textContent?.includes(name), + ); + expect(button).toBeTruthy(); + return button!; +} + +async function selectGridCard(name: string) { + const card = domainCard(name); + await userEvent.hover(card); + await expect.element(page.getByRole("checkbox", { name: `Select ${name}` })).toBeInTheDocument(); + await page.getByRole("checkbox", { name: `Select ${name}` }).click(); } describe("dashboard shell", () => { - beforeEach(() => { + beforeEach(async () => { resetDashboardTestState(); + await userEvent.unhover(document.body); }); afterEach(() => { @@ -75,182 +84,183 @@ describe("dashboard shell", () => { describe("view toggle and empty states", () => { it("renders the grid by default without a table", async () => { - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - expect(screen.getByRole("heading", { name: /Welcome back/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Grid view" })).toBeInTheDocument(); - expect(screen.queryByRole("table")).not.toBeInTheDocument(); + await expect.element(page.getByRole("heading", { name: /Welcome back/ })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Grid view" })).toBeInTheDocument(); + await expect.element(page.getByRole("table")).not.toBeInTheDocument(); expect(domainNames()).toEqual( expect.arrayContaining(["alpha.com", "beta.io", "gamma.com", "pending.dev"]), ); }); it("switches between grid and table", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.click(screen.getByRole("button", { name: "Table view" })); - await waitFor(() => { - expect(screen.getByRole("table")).toBeInTheDocument(); - }); - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); + await page.getByRole("button", { name: "Table view" }).click(); + await expect.element(page.getByRole("table")).toBeInTheDocument(); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Grid view" })); - await waitFor(() => { - expect(screen.queryByRole("table")).not.toBeInTheDocument(); - }); - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); + await page.getByRole("button", { name: "Grid view" }).click(); + await expect.element(page.getByRole("table")).not.toBeInTheDocument(); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); }); - it("shows the first-time empty state", () => { - renderDashboardShell({ domains: [], totalDomains: 0 }); + it("shows the first-time empty state", async () => { + await renderDashboardShell({ domains: [], totalDomains: 0 }); - expect(screen.getByText("Start tracking your domains")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /Add Your First Domain/ })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Grid view" })).not.toBeInTheDocument(); + await expect + .element(page.getByText("Start tracking your domains", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: /Add Your First Domain/ })) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Grid view" })).not.toBeInTheDocument(); }); it("shows a no-matches empty state and restores cards after clearing filters", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.type(screen.getByRole("textbox", { name: "Search domains" }), "zzzz"); - await waitFor(() => { - expect(screen.getByText("No domains match your filters")).toBeInTheDocument(); - }); + await page.getByRole("textbox", { name: "Search domains" }).fill("zzzz"); + await expect + .element(page.getByText("No domains match your filters", { exact: true })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear Filters" })); - await waitFor(() => { - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - }); + await page.getByRole("button", { name: "Clear Filters" }).click(); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); }); }); describe("grid", () => { it("shows status badges and complete-verification on unverified cards", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - expect(screen.getAllByText("Verified").length).toBeGreaterThan(0); - expect(screen.getByText("Pending")).toBeInTheDocument(); - expect(screen.getByText("Healthy")).toBeInTheDocument(); - expect(screen.getAllByText("Needs Attention").length).toBeGreaterThan(0); + expect(page.getByText("Verified", { exact: true }).length).toBeGreaterThan(0); + await expect.element(page.getByText("Pending", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Healthy", { exact: true })).toBeInTheDocument(); + expect(page.getByText("Needs Attention", { exact: true }).length).toBeGreaterThan(0); - await user.click(screen.getByRole("button", { name: /Complete Verification/ })); + await page.getByRole("button", { name: /Complete Verification/ }).click(); expect(dashboardActionSpies.onVerify).toHaveBeenCalledWith("domain-pending", null); }); it("archives, mutes, and removes a verified card from the actions menu", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" })); - await user.click(screen.getByRole("menuitem", { name: "Archive" })); + const alphaCard = domainCard("alpha.com"); + await userEvent.click(cardButton(alphaCard, "Actions")); + await expect.element(page.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); + await page.getByRole("menuitem", { name: "Archive" }).click(); expect(dashboardActionSpies.onArchive).toHaveBeenCalledWith("domain-alpha"); - await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" })); - await user.click(screen.getByRole("menuitem", { name: "Mute" })); + await userEvent.click(cardButton(alphaCard, "Actions")); + await expect.element(page.getByRole("menuitem", { name: "Mute" })).toBeInTheDocument(); + await page.getByRole("menuitem", { name: "Mute" }).click(); expect(dashboardActionSpies.onMute).toHaveBeenCalledWith("domain-alpha", true); - await user.click(within(domainCard("alpha.com")).getByRole("button", { name: "Actions" })); - await user.click(screen.getByRole("menuitem", { name: "Remove" })); + await userEvent.click(cardButton(alphaCard, "Actions")); + await expect.element(page.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); + await page.getByRole("menuitem", { name: "Remove" }).click(); expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-alpha"); }); it("reorders cards from the sort dropdown", async () => { - const user = userEvent.setup(); - const { urlUpdates } = renderDashboardShell(); + const { urlUpdates } = await renderDashboardShell(); await waitForCatalog(); - await user.click(screen.getByRole("button", { name: /Sort:/ })); - await user.click(screen.getByRole("menuitemradio", { name: "Name (Z-A)" })); + await page.getByRole("button", { name: /Sort:/ }).click(); + await expect + .element(page.getByRole("menuitemradio", { name: "Name (Z-A)" })) + .toBeInTheDocument(); + await page.getByRole("menuitemradio", { name: "Name (Z-A)" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(domainNames()).toEqual(["pending.dev", "gamma.com", "beta.io", "alpha.com"]); }); expect(urlUpdates.some((url) => url.includes("sort=domainName.desc"))).toBe(true); }); it("sorts by expiry from the dropdown and keeps unverified last", async () => { - const user = userEvent.setup(); - const { urlUpdates } = renderDashboardShell(); + const { urlUpdates } = await renderDashboardShell(); await waitForCatalog(); - await user.click(screen.getByRole("button", { name: /Sort:/ })); - await user.click(screen.getByRole("menuitemradio", { name: "Expiry (Soonest first)" })); + await page.getByRole("button", { name: /Sort:/ }).click(); + await expect + .element(page.getByRole("menuitemradio", { name: "Expiry (Soonest first)" })) + .toBeInTheDocument(); + await page.getByRole("menuitemradio", { name: "Expiry (Soonest first)" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(domainNames()).toEqual(["gamma.com", "beta.io", "alpha.com", "pending.dev"]); }); expect(urlUpdates.some((url) => url.includes("sort=expirationDate.asc"))).toBe(true); }); it("selects a card and shows the bulk toolbar", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("toolbar", { name: "Bulk actions" })) + .not.toBeInTheDocument(); - await selectGridCard(user, "alpha.com"); + await selectGridCard("alpha.com"); - const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" }); - expect(within(toolbar).getByText("1 selected")).toBeInTheDocument(); + const toolbar = page.getByRole("toolbar", { name: "Bulk actions" }); + await expect.element(toolbar).toBeInTheDocument(); + await expect.element(toolbar.getByText("1 selected", { exact: true })).toBeInTheDocument(); }); }); describe("table", () => { - async function openTable(user: ReturnType) { - await user.click(screen.getByRole("button", { name: "Table view" })); - await waitFor(() => { - expect(screen.getByRole("table")).toBeInTheDocument(); - }); + async function openTable() { + await page.getByRole("button", { name: "Table view" }).click(); + await expect.element(page.getByRole("table")).toBeInTheDocument(); } it("renders domain links and unverified continue/remove actions", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await openTable(user); + await openTable(); - const table = screen.getByRole("table"); - expect(within(table).getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); + const table = page.getByRole("table"); + await expect.element(table.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - await user.click(within(table).getByRole("button", { name: "Continue" })); + await table.getByRole("button", { name: "Continue" }).click(); expect(dashboardActionSpies.onVerify).toHaveBeenCalledWith("domain-pending", null); - await user.click(within(table).getByRole("button", { name: "Remove" })); + await table.getByRole("button", { name: "Remove" }).click(); expect(dashboardActionSpies.onRemove).toHaveBeenCalledWith("domain-pending"); }); it("toggles sort from the domain header and keeps unverified last on expiry", async () => { - const user = userEvent.setup(); - const { urlUpdates } = renderDashboardShell(); + const { urlUpdates } = await renderDashboardShell(); await waitForCatalog(); - await openTable(user); + await openTable(); - await user.click(screen.getByRole("button", { name: /^Domain$/ })); - await waitFor(() => { + await page.getByRole("button", { name: /^Domain$/ }).click(); + await vi.waitFor(() => { expect(urlUpdates.some((url) => url.includes("sort=domainName.desc"))).toBe(true); }); - await user.click(screen.getByRole("button", { name: /^Expires$/ })); - await waitFor(() => { - const names = within(screen.getByRole("table")) - .getAllByRole("link") + await page.getByRole("button", { name: /^Expires$/ }).click(); + await vi.waitFor(() => { + const names = page + .getByRole("table") + .getByRole("link") + .elements() .map((el) => el.textContent?.trim()); expect(names.at(-1)).toBe("pending.dev"); }); }); it("sorts the domain column case-insensitively", async () => { - const user = userEvent.setup(); - renderDashboardShell({ + await renderDashboardShell({ domains: [ makeTrackedDomain({ id: "domain-zeta", domainName: "Zeta.com" }), makeTrackedDomain({ id: "domain-alpha", domainName: "alpha.com" }), @@ -258,361 +268,346 @@ describe("dashboard shell", () => { ], }); await waitForCatalog(); - await openTable(user); + await openTable(); // The domain column has no explicit `sortFn`, so it resolves `"auto"` -> // `text` from the registry on `dashboardTableFeatures`. Without that // registration it silently falls back to `basic`, which sorts by code // point and puts every capitalized domain ahead of the lowercase ones. - await waitFor(() => { - const names = within(screen.getByRole("table")) - .getAllByRole("link") + await vi.waitFor(() => { + const names = page + .getByRole("table") + .getByRole("link") + .elements() .map((el) => el.textContent?.trim()); expect(names).toEqual(["alpha.com", "Beta.io", "Zeta.com"]); }); }); it("paginates and resets the page when page size changes", async () => { - const user = userEvent.setup(); - const { urlUpdates } = renderDashboardShell({ domains: makePaginationDomains(12) }); - await waitFor(() => { - expect(screen.getByRole("link", { name: "site00.com" })).toBeInTheDocument(); - }); - await openTable(user); + const { urlUpdates } = await renderDashboardShell({ domains: makePaginationDomains(12) }); + await expect.element(page.getByRole("link", { name: "site00.com" })).toBeInTheDocument(); + await openTable(); - const table = screen.getByRole("table"); - expect(within(table).getAllByRole("link")).toHaveLength(10); - expect(screen.getByText("1 of 2")).toBeInTheDocument(); + const table = page.getByRole("table"); + expect(table.getByRole("link").length).toBe(10); + await expect.element(page.getByText("1 of 2", { exact: true })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Go to next page" })); - await waitFor(() => { - expect(screen.getByText("2 of 2")).toBeInTheDocument(); - expect( - within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }), - ).not.toBeInTheDocument(); - expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(2); - }); + await page.getByRole("button", { name: "Go to next page" }).click(); + await expect.element(page.getByText("2 of 2", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .not.toBeInTheDocument(); + expect(page.getByRole("table").getByRole("link").length).toBe(2); expect(urlUpdates.some((url) => /(?:^|[?&])page=2(?:&|$)/.test(url))).toBe(true); - const pageSize = screen.getByRole("combobox", { name: "Domains per page" }); - await user.click(pageSize); - await user.click(await screen.findByRole("option", { name: "25" })); - await waitFor(() => { - expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(12); + const pageSize = page.getByRole("combobox", { name: "Domains per page" }); + await pageSize.click(); + await expect.element(page.getByRole("option", { name: "25" })).toBeInTheDocument(); + await page.getByRole("option", { name: "25" }).click(); + await vi.waitFor(() => { + expect(page.getByRole("table").getByRole("link").length).toBe(12); }); - expect(screen.getByText("1 of 1")).toBeInTheDocument(); + await expect.element(page.getByText("1 of 1", { exact: true })).toBeInTheDocument(); }); it("returns to page 1 when filters change even if page 2 still has rows", async () => { - const user = userEvent.setup(); usePreferencesStore.setState({ viewMode: "table" }); - renderDashboardShell({ + await renderDashboardShell({ domains: makePaginationDomains(12), searchParams: "page=2", }); - await waitFor(() => { - expect(screen.getByText("2 of 2")).toBeInTheDocument(); - expect( - within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }), - ).not.toBeInTheDocument(); - }); + await expect.element(page.getByText("2 of 2", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Grid view" })); + await page.getByRole("button", { name: "Grid view" }).click(); // "s" still matches all 12 sites, so clamp alone would leave page 2 empty of site00. - await user.type(screen.getAllByRole("textbox", { name: "Search domains" })[0], "s"); - await waitFor(() => { + await userEvent.type( + page.getByRole("textbox", { name: "Search domains" }).first().element(), + "s", + ); + await vi.waitFor(() => { expect(domainNames()).toEqual(expect.arrayContaining(["site00.com", "site11.com"])); }); - await user.click(screen.getByRole("button", { name: "Table view" })); - await waitFor(() => { - expect( - within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }), - ).toBeInTheDocument(); - expect(screen.getByText("1 of 2")).toBeInTheDocument(); - }); + await page.getByRole("button", { name: "Table view" }).click(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .toBeInTheDocument(); + await expect.element(page.getByText("1 of 2", { exact: true })).toBeInTheDocument(); }); it("clamps an impossible deep-linked page to page 1", async () => { usePreferencesStore.setState({ viewMode: "table" }); - renderDashboardShell({ + await renderDashboardShell({ domains: makePaginationDomains(2), searchParams: "page=2", }); - await waitFor(() => { - expect(screen.getByRole("table")).toBeInTheDocument(); - expect( - within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }), - ).toBeInTheDocument(); - expect(screen.getByText("1 of 1")).toBeInTheDocument(); - }); + await expect.element(page.getByRole("table")).toBeInTheDocument(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .toBeInTheDocument(); + await expect.element(page.getByText("1 of 1", { exact: true })).toBeInTheDocument(); }); it("keeps a deep-linked page when filters are not changed", async () => { - const user = userEvent.setup(); usePreferencesStore.setState({ viewMode: "table" }); - renderDashboardShell({ + await renderDashboardShell({ domains: makePaginationDomains(12), searchParams: "page=2", }); - await waitFor(() => { - expect(screen.getByRole("table")).toBeInTheDocument(); - expect(screen.getByText("2 of 2")).toBeInTheDocument(); - }); - expect( - within(screen.getByRole("table")).queryByRole("link", { name: "site00.com" }), - ).not.toBeInTheDocument(); - expect(within(screen.getByRole("table")).getAllByRole("link")).toHaveLength(2); + await expect.element(page.getByRole("table")).toBeInTheDocument(); + await expect.element(page.getByText("2 of 2", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .not.toBeInTheDocument(); + expect(page.getByRole("table").getByRole("link").length).toBe(2); - await user.click(screen.getByRole("button", { name: "Go to previous page" })); - await waitFor(() => { - expect( - within(screen.getByRole("table")).getByRole("link", { name: "site00.com" }), - ).toBeInTheDocument(); - }); + await page.getByRole("button", { name: "Go to previous page" }).click(); + await expect + .element(page.getByRole("table").getByRole("link", { name: "site00.com" })) + .toBeInTheDocument(); }); it("hides and restores a column from the column menu", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await openTable(user); + await openTable(); - expect(screen.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument(); - await user.click(screen.getAllByRole("button", { name: "Toggle columns" })[0]); - await user.click(await screen.findByRole("menuitemcheckbox", { name: /Registrar/ })); + await page.getByRole("button", { name: "Toggle columns" }).first().click(); + await expect + .element(page.getByRole("menuitemcheckbox", { name: /Registrar/ })) + .toBeInTheDocument(); + await page.getByRole("menuitemcheckbox", { name: /Registrar/ }).click(); - await waitFor(() => { - expect(screen.queryByRole("button", { name: /^Registrar$/ })).not.toBeInTheDocument(); - }); + await expect + .element(page.getByRole("button", { name: /^Registrar$/ })) + .not.toBeInTheDocument(); - await user.click(screen.getByRole("menuitem", { name: /Show all columns/ })); - await waitFor(() => { - expect(screen.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument(); - }); + await expect + .element(page.getByRole("menuitem", { name: /Show all columns/ })) + .toBeInTheDocument(); + await page.getByRole("menuitem", { name: /Show all columns/ }).click(); + await expect.element(page.getByRole("button", { name: /^Registrar$/ })).toBeInTheDocument(); }); it("selects a row and shows the bulk toolbar", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await openTable(user); + await openTable(); - await user.click(screen.getByRole("checkbox", { name: "Select alpha.com" })); - expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); + await page.getByRole("checkbox", { name: "Select alpha.com" }).click(); + await expect.element(page.getByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); }); }); describe("filters", () => { it("filters by search and restores after clearing the chip", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.type(screen.getByRole("textbox", { name: "Search domains" }), "beta"); - await waitFor(() => { + await page.getByRole("textbox", { name: "Search domains" }).fill("beta"); + await vi.waitFor(() => { expect(domainNames()).toEqual(["beta.io"]); }); - expect(screen.getByText('"beta"')).toBeInTheDocument(); + await expect.element(page.getByText('"beta"', { exact: true })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Remove search filter" })); - await waitFor(() => { + await page.getByRole("button", { name: "Remove search filter" }).click(); + await vi.waitFor(() => { expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "beta.io"])); }); }); it("filters by health from the dropdown and supports clear all", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await user.click(getFilterTrigger(/^Health/)); - await user.click(await screen.findByRole("option", { name: "Expiring Soon" })); + await getFilterTrigger(/^Health/).click(); + await expect.element(page.getByRole("option", { name: "Expiring Soon" })).toBeInTheDocument(); + await page.getByRole("option", { name: "Expiring Soon" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(domainNames()).toEqual(["beta.io"]); }); - expect(screen.getByRole("button", { name: "Remove health filter" })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Remove health filter" })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear all" })); - await waitFor(() => { + await page.getByRole("button", { name: "Clear all" }).click(); + await vi.waitFor(() => { expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "gamma.com"])); }); }); it("filters by TLD and provider from initial search params", async () => { - renderDashboardShell({ searchParams: "tlds=io" }); - await waitFor(() => { + await renderDashboardShell({ searchParams: "tlds=io" }); + await vi.waitFor(() => { expect(domainNames()).toEqual(["beta.io"]); }); - expect(screen.getByText(".io")).toBeInTheDocument(); + await expect.element(page.getByText(".io", { exact: true })).toBeInTheDocument(); }); it("filters by provider from initial search params", async () => { - renderDashboardShell({ searchParams: "providers=cloudflare" }); - await waitFor(() => { + await renderDashboardShell({ searchParams: "providers=cloudflare" }); + await vi.waitFor(() => { expect(domainNames().sort()).toEqual(["alpha.com", "beta.io"]); }); }); it("applies pending and expiring filters from the health summary", async () => { - const user = userEvent.setup(); - renderDashboardShell(); - await waitFor(() => { - expect( - screen.getByRole("button", { name: "Filter by pending verification" }), - ).toBeInTheDocument(); - }); - expect(screen.getByRole("button", { name: "Filter by expiring domains" })).toHaveTextContent( - "1expiring soon", - ); + await renderDashboardShell(); + await expect + .element(page.getByRole("button", { name: "Filter by pending verification" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Filter by expiring domains" })) + .toHaveTextContent("1expiring soon"); - await user.click(screen.getByRole("button", { name: "Filter by pending verification" })); - await waitFor(() => { + await page.getByRole("button", { name: "Filter by pending verification" }).click(); + await vi.waitFor(() => { expect(domainNames()).toEqual(["pending.dev"]); }); - expect(screen.getByRole("button", { name: "Remove status filter" })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Remove status filter" })) + .toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear all" })); - await user.click(screen.getByRole("button", { name: "Filter by expiring domains" })); - await waitFor(() => { + await page.getByRole("button", { name: "Clear all" }).click(); + await page.getByRole("button", { name: "Filter by expiring domains" }).click(); + await vi.waitFor(() => { expect(domainNames()).toEqual(["beta.io"]); }); }); it("removes one chip without clearing the rest", async () => { - const user = userEvent.setup(); - renderDashboardShell({ searchParams: "search=a&tlds=com" }); - await waitFor(() => { + await renderDashboardShell({ searchParams: "search=a&tlds=com" }); + await vi.waitFor(() => { expect(domainNames().sort()).toEqual(["alpha.com", "gamma.com"]); }); - await user.click(screen.getByRole("button", { name: "Remove tld filter" })); - await waitFor(() => { + await page.getByRole("button", { name: "Remove tld filter" }).click(); + await vi.waitFor(() => { expect(domainNames()).toEqual(expect.arrayContaining(["alpha.com", "beta.io"])); }); - expect(screen.getByText('"a"')).toBeInTheDocument(); + await expect.element(page.getByText('"a"', { exact: true })).toBeInTheDocument(); }); it("pins a domain from domainId search params", async () => { - renderDashboardShell({ searchParams: "domainId=domain-alpha" }); - await waitFor(() => { + await renderDashboardShell({ searchParams: "domainId=domain-alpha" }); + await vi.waitFor(() => { expect(domainNames()).toEqual(["alpha.com"]); }); - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - expect(screen.getByText("Domain:")).toBeInTheDocument(); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); + await expect.element(page.getByText("Domain:", { exact: true })).toBeInTheDocument(); }); }); describe("bulk toolbar", () => { it("archives, deletes, mutes, unmutes, cancels, and select-alls visible ids", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await selectGridCard(user, "alpha.com"); - await selectGridCard(user, "beta.io"); + await selectGridCard("alpha.com"); + await selectGridCard("beta.io"); - const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" }); - expect(within(toolbar).getByText("2 selected")).toBeInTheDocument(); + const toolbar = page.getByRole("toolbar", { name: "Bulk actions" }); + await expect.element(toolbar).toBeInTheDocument(); + await expect.element(toolbar.getByText("2 selected", { exact: true })).toBeInTheDocument(); - await user.click(within(toolbar).getByRole("button", { name: "Mute" })); + await toolbar.getByRole("button", { name: "Mute" }).click(); expect(dashboardActionSpies.onBulkMute).toHaveBeenCalledWith( ["domain-alpha", "domain-beta"], true, ); - await user.click(within(toolbar).getByRole("button", { name: "Unmute" })); + await toolbar.getByRole("button", { name: "Unmute" }).click(); expect(dashboardActionSpies.onBulkMute).toHaveBeenCalledWith( ["domain-alpha", "domain-beta"], false, ); - await user.click(within(toolbar).getByRole("button", { name: "Archive" })); + await toolbar.getByRole("button", { name: "Archive" }).click(); expect(dashboardActionSpies.onBulkArchive).toHaveBeenCalledWith([ "domain-alpha", "domain-beta", ]); - await user.click(within(toolbar).getByRole("button", { name: "Delete" })); + await toolbar.getByRole("button", { name: "Delete" }).click(); expect(dashboardActionSpies.onBulkDelete).toHaveBeenCalledWith([ "domain-alpha", "domain-beta", ]); - await user.click(within(toolbar).getByRole("button", { name: "Cancel selection" })); - await waitFor(() => { - expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); - }); + await toolbar.getByRole("button", { name: "Cancel selection" }).click(); + await expect + .element(page.getByRole("toolbar", { name: "Bulk actions" })) + .not.toBeInTheDocument(); - await selectGridCard(user, "alpha.com"); - const toolbarAgain = await screen.findByRole("toolbar", { name: "Bulk actions" }); - await user.click(within(toolbarAgain).getByRole("checkbox")); - await waitFor(() => { - expect(within(toolbarAgain).getByText("4 selected")).toBeInTheDocument(); - }); + await selectGridCard("alpha.com"); + const toolbarAgain = page.getByRole("toolbar", { name: "Bulk actions" }); + await expect.element(toolbarAgain).toBeInTheDocument(); + await toolbarAgain.getByRole("checkbox").click(); + await expect + .element(toolbarAgain.getByText("4 selected", { exact: true })) + .toBeInTheDocument(); }); it("selects only the filtered visible ids", async () => { - const user = userEvent.setup(); - renderDashboardShell({ searchParams: "tlds=com" }); - await waitFor(() => { + await renderDashboardShell({ searchParams: "tlds=com" }); + await vi.waitFor(() => { expect(domainNames().sort()).toEqual(["alpha.com", "gamma.com"]); }); - await selectGridCard(user, "alpha.com"); - const toolbar = await screen.findByRole("toolbar", { name: "Bulk actions" }); - await user.click(within(toolbar).getByRole("checkbox")); - await waitFor(() => { - expect(within(toolbar).getByText("2 selected")).toBeInTheDocument(); - }); + await selectGridCard("alpha.com"); + const toolbar = page.getByRole("toolbar", { name: "Bulk actions" }); + await expect.element(toolbar).toBeInTheDocument(); + await toolbar.getByRole("checkbox").click(); + await expect.element(toolbar.getByText("2 selected", { exact: true })).toBeInTheDocument(); }); it("drops hidden domains from the selection when filters change", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await selectGridCard(user, "alpha.com"); - expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); + await selectGridCard("alpha.com"); + await expect.element(page.getByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); - await user.type(screen.getByRole("textbox", { name: "Search domains" }), "beta"); - await waitFor(() => { + await page.getByRole("textbox", { name: "Search domains" }).fill("beta"); + await vi.waitFor(() => { expect(domainNames()).toEqual(["beta.io"]); - expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); }); + await expect + .element(page.getByRole("toolbar", { name: "Bulk actions" })) + .not.toBeInTheDocument(); }); it("clears selection on Escape", async () => { - const user = userEvent.setup(); - renderDashboardShell(); + await renderDashboardShell(); await waitForCatalog(); - await selectGridCard(user, "alpha.com"); - expect(await screen.findByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); + await selectGridCard("alpha.com"); + await expect.element(page.getByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); - await user.keyboard("{Escape}"); - await waitFor(() => { - expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); - }); + await userEvent.keyboard("{Escape}"); + await expect + .element(page.getByRole("toolbar", { name: "Bulk actions" })) + .not.toBeInTheDocument(); }); }); describe("preferences", () => { it("opens in table view when the preference is already table", async () => { usePreferencesStore.setState({ viewMode: "table" }); - renderDashboardShell(); - await waitFor(() => { - expect(screen.getByRole("table")).toBeInTheDocument(); - }); + await renderDashboardShell(); + await expect.element(page.getByRole("table")).toBeInTheDocument(); // Stay mounted long enough that a table-wrapper setState loop would throw. - await waitFor(() => { - expect(screen.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); - }); + await expect.element(page.getByRole("link", { name: "alpha.com" })).toBeInTheDocument(); }); }); }); diff --git a/apps/web/components/dashboard/test-utils.tsx b/apps/web/components/dashboard/test-utils.tsx index 5e0cd0af..b5008936 100644 --- a/apps/web/components/dashboard/test-utils.tsx +++ b/apps/web/components/dashboard/test-utils.tsx @@ -262,14 +262,14 @@ export type RenderDashboardShellOptions = { confirmActions?: boolean; }; -export function renderDashboardShell(options: RenderDashboardShellOptions = {}) { +export async function renderDashboardShell(options: RenderDashboardShellOptions = {}) { const domains = options.domains ?? makeDashboardDomains(); const totalDomains = options.totalDomains ?? domains.length; mockSubscription.activeCount = totalDomains; const urlUpdates: string[] = []; - const view = render( + const view = await render( diff --git a/apps/web/components/domain/certificates/certificates-section.test.tsx b/apps/web/components/domain/certificates/certificates-section.test.tsx index 4fa9a384..e59c7ed3 100644 --- a/apps/web/components/domain/certificates/certificates-section.test.tsx +++ b/apps/web/components/domain/certificates/certificates-section.test.tsx @@ -1,7 +1,7 @@ -import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { CertificatesSection, equalHostname } from "./certificates-section"; @@ -71,37 +71,41 @@ describe("CertificatesSection", () => { }, ], }; - render(); - expect(screen.getByText("Issuer")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("Issuer", { exact: true })).toBeInTheDocument(); expect( - screen.getAllByText("Let's Encrypt").some((n) => n.tagName.toLowerCase() === "span"), + page + .getByText("Let's Encrypt", { exact: true }) + .elements() + .some((n) => n.tagName.toLowerCase() === "span"), ).toBe(true); - expect(screen.getByText("Subject")).toBeInTheDocument(); + await expect.element(page.getByText("Subject", { exact: true })).toBeInTheDocument(); // Assert SAN count badge - altNames has 2 items but "example.com" matches subject, so +1 - expect(screen.getByText("+")).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); + await expect.element(page.getByText("+", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("1", { exact: true })).toBeInTheDocument(); // Assert tooltip wrapper and content with SAN domains - expect(screen.getByRole("button", { name: /\+1/i })).toBeInTheDocument(); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: /\+1/i })); - expect(await screen.findByText("*.test.invalid")).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /\+\s*1/ })).toBeInTheDocument(); + await page.getByRole("button", { name: /\+\s*1/ }).click(); + await expect.element(page.getByText("*.test.invalid", { exact: true })).toBeInTheDocument(); // Assert CA provider logo - const providerLogo = screen.getByTestId("provider-logo"); - expect(providerLogo).toHaveAttribute("data-provider-id", "ca-letsencrypt"); + await expect + .element(page.getByTestId("provider-logo")) + .toHaveAttribute("data-provider-id", "ca-letsencrypt"); // Assert CA provider name displayed as annotation - const caProviderName = screen - .getAllByText("Let's Encrypt") + const caProviderName = page + .getByText("Let's Encrypt", { exact: true }) + .elements() .find((n) => n.className.includes("text-[11px]")); - expect(caProviderName).toBeInTheDocument(); + expect(caProviderName).toBeDefined(); }); - it("shows empty state when no certificates", () => { - render(); - expect(screen.getByText(/No certificates found/i)).toBeInTheDocument(); + it("shows empty state when no certificates", async () => { + await render(); + await expect.element(page.getByText(/No certificates found/i)).toBeInTheDocument(); }); it("expands and collapses the rest of the certificate chain", async () => { @@ -137,32 +141,32 @@ describe("CertificatesSection", () => { }, ], }; - const user = userEvent.setup(); - render(); + await render(); // Subject appears as both the truncated label and tooltip content const chainSubject = () => - screen.getAllByText("R3").find((node) => node.tagName.toLowerCase() === "span"); + page + .getByText("R3", { exact: true }) + .elements() + .find((node) => node.tagName.toLowerCase() === "span"); - expect(screen.getByRole("button", { name: "Show Chain" })).toHaveAttribute( - "aria-expanded", - "false", - ); + await expect + .element(page.getByRole("button", { name: "Show Chain" })) + .toHaveAttribute("aria-expanded", "false"); expect(chainSubject()?.closest("[inert]")).not.toBeNull(); expect(chainSubject()?.closest('[aria-hidden="true"]')).not.toBeNull(); - await user.click(screen.getByRole("button", { name: "Show Chain" })); + await page.getByRole("button", { name: "Show Chain" }).click(); expect(chainSubject()?.closest("[inert]")).toBeNull(); expect(chainSubject()?.closest('[aria-hidden="true"]')).toBeNull(); - expect(screen.getByRole("button", { name: "Hide Chain" })).toHaveAttribute( - "aria-expanded", - "true", - ); + await expect + .element(page.getByRole("button", { name: "Hide Chain" })) + .toHaveAttribute("aria-expanded", "true"); - await user.click(screen.getByRole("button", { name: "Hide Chain" })); + await page.getByRole("button", { name: "Hide Chain" }).click(); - expect(screen.getByRole("button", { name: "Show Chain" })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Show Chain" })).toBeInTheDocument(); expect(chainSubject()?.closest("[inert]")).not.toBeNull(); expect(chainSubject()?.closest('[aria-hidden="true"]')).not.toBeNull(); }); diff --git a/apps/web/components/domain/dns/dns-record-list.test.tsx b/apps/web/components/domain/dns/dns-record-list.test.tsx index 5cd2e534..d07aa0e6 100644 --- a/apps/web/components/domain/dns/dns-record-list.test.tsx +++ b/apps/web/components/domain/dns/dns-record-list.test.tsx @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; import { DnsRecordList } from "@/components/domain/dns/dns-record-list"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; vi.mock("@/components/icons/favicon", () => ({ Favicon: ({ domain }: { domain: string }) =>
icon:{domain}
, @@ -28,7 +29,7 @@ vi.mock("@/components/ui/tooltip", () => ({ })); describe("DnsRecordList", () => { - it("renders MX with TTL badges (sorting handled server-side)", () => { + it("renders MX with TTL badges (sorting handled server-side)", async () => { const records = [ { type: "MX", @@ -53,7 +54,7 @@ describe("DnsRecordList", () => { }, ] as unknown as import("@domainstack/types").DnsRecord[]; - render(); + await render(); // KeyValue now renders the value span with classes: "min-w-0 flex-1 truncate" const items = Array.from(document.querySelectorAll("span.min-w-0.flex-1.truncate")).map( @@ -68,12 +69,12 @@ describe("DnsRecordList", () => { expect(document.querySelectorAll('[data-slot="badge"]')).toBeTruthy(); }); - it("shows Cloudflare favicon suffix when isCloudflare", () => { + it("shows Cloudflare favicon suffix when isCloudflare", async () => { const records = [ { type: "A", name: "", value: "1.2.3.4", ttl: 60, isCloudflare: true }, ] as unknown as import("@domainstack/types").DnsRecord[]; - render(); - expect(screen.getByText(/icon:cloudflare.com/i)).toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/icon:cloudflare.com/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/dns/dns-section.test.tsx b/apps/web/components/domain/dns/dns-section.test.tsx index 4cd3ebee..2bcd2069 100644 --- a/apps/web/components/domain/dns/dns-section.test.tsx +++ b/apps/web/components/domain/dns/dns-section.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { DnsSection } from "./dns-section"; @@ -27,7 +28,7 @@ vi.mock("@/components/domain/dns/dns-record-list", () => ({ })); describe("DnsSection", () => { - it("renders groups for each type and passes counts", () => { + it("renders groups for each type and passes counts", async () => { const records = [ { type: "A", name: "a", value: "1.2.3.4" }, { type: "AAAA", name: "aaaa", value: "::1" }, @@ -36,16 +37,15 @@ describe("DnsSection", () => { { type: "NS", name: "ns", value: "ns1.test.invalid" }, ] as unknown as import("@domainstack/types").DnsRecord[]; - render(); + await render(); - expect(screen.getByText("A Records")).toBeInTheDocument(); - const counts = screen.getAllByText("count:1"); - expect(counts.length).toBe(5); - expect(screen.getByText("MX Records")).toBeInTheDocument(); + await expect.element(page.getByText("A Records", { exact: true })).toBeInTheDocument(); + expect(page.getByText("count:1", { exact: true }).length).toBe(5); + await expect.element(page.getByText("MX Records", { exact: true })).toBeInTheDocument(); }); - it("shows empty state when no records", () => { - render(); - expect(screen.getByText(/No DNS records found/i)).toBeInTheDocument(); + it("shows empty state when no records", async () => { + await render(); + await expect.element(page.getByText(/No DNS records found/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/headers/headers-section.test.tsx b/apps/web/components/domain/headers/headers-section.test.tsx index 55f179a6..d0ffb390 100644 --- a/apps/web/components/domain/headers/headers-section.test.tsx +++ b/apps/web/components/domain/headers/headers-section.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { HeadersSection } from "./headers-section"; @@ -24,7 +25,7 @@ vi.mock("@/components/ui/tooltip", () => ({ })); describe("HeadersSection", () => { - it("highlights important headers and renders values", () => { + it("highlights important headers and renders values", async () => { const data = { headers: [ { name: "strict-transport-security", value: "max-age=63072000" }, @@ -33,13 +34,19 @@ describe("HeadersSection", () => { ], status: 200, }; - render(); - expect(screen.getByText("strict-transport-security")).toBeInTheDocument(); - const values = screen.getAllByText("max-age=63072000"); - expect(values.some((n) => n.tagName.toLowerCase() === "span")).toBe(true); + await render(); + await expect + .element(page.getByText("strict-transport-security", { exact: true })) + .toBeInTheDocument(); + expect( + page + .getByText("max-age=63072000", { exact: true }) + .elements() + .some((n) => n.tagName.toLowerCase() === "span"), + ).toBe(true); }); - it("sorts headers with important ones first, then alphabetically", () => { + it("sorts headers with important ones first, then alphabetically", async () => { const data = { headers: [ { name: "x-custom", value: "value1" }, @@ -50,7 +57,7 @@ describe("HeadersSection", () => { ], status: 200, }; - const { container } = render(); + const { container } = await render(); // Get all header label elements (they have uppercase styling via CSS) const allText = (container.textContent || "").toUpperCase(); @@ -80,53 +87,56 @@ describe("HeadersSection", () => { expect(xCustomPos).toBeLessThan(zebraPos); }); - it("shows empty state when no headers", () => { - render(); - expect(screen.getByText(/No HTTP headers detected/i)).toBeInTheDocument(); + it("shows empty state when no headers", async () => { + await render(); + await expect.element(page.getByText(/No HTTP headers detected/i)).toBeInTheDocument(); }); - it("renders location header with link to destination domain", () => { + it("renders location header with link to destination domain", async () => { const data = { headers: [{ name: "location", value: "https://www.test.invalid/path" }], status: 301, }; - render(); - expect(screen.getByText("location")).toBeInTheDocument(); - expect(screen.getByText("https://www.test.invalid/path")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("location", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByText("https://www.test.invalid/path", { exact: true })) + .toBeInTheDocument(); // Check that the link is rendered with correct href - const link = screen.getByTitle("View report for test.invalid"); - expect(link).toHaveAttribute("href", "/test.invalid"); + await expect + .element(page.getByTitle("View report for test.invalid")) + .toHaveAttribute("href", "/test.invalid"); }); - it("renders location header without link for relative URLs", () => { + it("renders location header without link for relative URLs", async () => { const data = { headers: [{ name: "location", value: "/relative/path" }], status: 302, }; - render(); - expect(screen.getByText("location")).toBeInTheDocument(); - expect(screen.getByText("/relative/path")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("location", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("/relative/path", { exact: true })).toBeInTheDocument(); // Should not have a link for relative URLs - expect(screen.queryByTitle(/View report for/)).not.toBeInTheDocument(); + await expect.element(page.getByTitle(/View report for/)).not.toBeInTheDocument(); }); - it("shows alert for non-200 status codes", () => { + it("shows alert for non-200 status codes", async () => { const data = { headers: [{ name: "server", value: "nginx" }], status: 404, statusMessage: "Not Found", }; - render(); + await render(); // Check that alert is displayed with link - expect(screen.getByText(/Server returned/)).toBeInTheDocument(); - expect(screen.getByText(/404/)).toBeInTheDocument(); - expect(screen.getByText(/Not Found/)).toBeInTheDocument(); + await expect.element(page.getByText(/Server returned/)).toBeInTheDocument(); + await expect.element(page.getByText(/404/)).toBeInTheDocument(); + await expect.element(page.getByText(/Not Found/)).toBeInTheDocument(); }); - it("filters out headers with empty values", () => { + it("filters out headers with empty values", async () => { const data = { headers: [ { name: "server", value: "nginx" }, @@ -137,28 +147,30 @@ describe("HeadersSection", () => { status: 200, statusMessage: "OK", }; - render(); + await render(); // Check that only non-empty headers are rendered - expect(screen.getByText("server")).toBeInTheDocument(); - expect(screen.getByText("nginx")).toBeInTheDocument(); - expect(screen.getByText("x-powered-by")).toBeInTheDocument(); - expect(screen.getByText("nextjs")).toBeInTheDocument(); + await expect.element(page.getByText("server", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("nginx", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("x-powered-by", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("nextjs", { exact: true })).toBeInTheDocument(); // Empty headers should not be rendered - expect(screen.queryByText("empty-header")).not.toBeInTheDocument(); - expect(screen.queryByText("whitespace-header")).not.toBeInTheDocument(); + await expect.element(page.getByText("empty-header", { exact: true })).not.toBeInTheDocument(); + await expect + .element(page.getByText("whitespace-header", { exact: true })) + .not.toBeInTheDocument(); }); - it("does not show alert for 200 status code", () => { + it("does not show alert for 200 status code", async () => { const data = { headers: [{ name: "server", value: "nginx" }], status: 200, statusMessage: "OK", }; - render(); + await render(); // Check that alert is NOT displayed - expect(screen.queryByText(/HTTP 200/)).not.toBeInTheDocument(); + await expect.element(page.getByText(/HTTP 200/)).not.toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/hosting/hosting-section.test.tsx b/apps/web/components/domain/hosting/hosting-section.test.tsx index a6e36606..e923da96 100644 --- a/apps/web/components/domain/hosting/hosting-section.test.tsx +++ b/apps/web/components/domain/hosting/hosting-section.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { HostingSection } from "./hosting-section"; @@ -20,7 +21,7 @@ vi.mock("@/components/domain/hosting/hosting-map-client", () => ({ })); describe("HostingSection", () => { - it("renders provider names and icons", () => { + it("renders provider names and icons", async () => { const data = { dnsProvider: { id: "provider-cloudflare", @@ -46,15 +47,15 @@ describe("HostingSection", () => { lon: null, }, } as unknown as import("@domainstack/types").HostingResponse; - render(); - expect(screen.getByText("Cloudflare")).toBeInTheDocument(); - expect(screen.getByText(/logo:provider-cloudflare/)).toBeInTheDocument(); - expect(screen.getByText("Vercel")).toBeInTheDocument(); - expect(screen.getByText("Google Workspace")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("Cloudflare", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText(/logo:provider-cloudflare/)).toBeInTheDocument(); + await expect.element(page.getByText("Vercel", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Google Workspace", { exact: true })).toBeInTheDocument(); }); - it("shows empty state when no providers", () => { - render(); - expect(screen.getByText(/No hosting details available/i)).toBeInTheDocument(); + it("shows empty state when no providers", async () => { + await render(); + await expect.element(page.getByText(/No hosting details available/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/registration/raw-data-dialog.test.tsx b/apps/web/components/domain/registration/raw-data-dialog.test.tsx index 1f3a7e65..dcc97b3a 100644 --- a/apps/web/components/domain/registration/raw-data-dialog.test.tsx +++ b/apps/web/components/domain/registration/raw-data-dialog.test.tsx @@ -1,8 +1,8 @@ -import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; import { RawDataDialog } from "@/components/domain/registration/raw-data-dialog"; -import { render, screen, within } from "@/mocks/react"; +import { render } from "@/mocks/react"; vi.mock("@/components/icons/favicon", () => ({ Favicon: ({ domain }: { domain: string }) =>
, @@ -10,9 +10,7 @@ vi.mock("@/components/icons/favicon", () => ({ describe("RawDataDialog", () => { it("highlights RDAP JSON keys, strings, numbers, booleans, and null", async () => { - const user = userEvent.setup(); - - render( + await render( { />, ); - await user.click(screen.getByRole("button", { name: "View raw RDAP data" })); + await page.getByRole("button", { name: "View raw RDAP data" }).click(); - const dialog = await screen.findByRole("dialog"); - const code = within(dialog).getByLabelText("Raw RDAP data"); + await expect.element(page.getByRole("dialog")).toBeInTheDocument(); + const code = page.getByLabelText("Raw RDAP data"); - expect(within(code).getByText('"ldhName"')).toHaveClass("text-blue-700"); - expect(within(code).getByText('"example.com"')).toHaveClass("text-emerald-700"); - expect(within(code).getByText("123")).toHaveClass("text-amber-700"); - expect(within(code).getByText("false")).toHaveClass("text-violet-700"); - expect(within(code).getByText("null")).toHaveClass("text-stone-500"); + await expect.element(code.getByText('"ldhName"', { exact: true })).toHaveClass("text-blue-700"); + await expect + .element(code.getByText('"example.com"', { exact: true })) + .toHaveClass("text-emerald-700"); + await expect.element(code.getByText("123", { exact: true })).toHaveClass("text-amber-700"); + await expect.element(code.getByText("false", { exact: true })).toHaveClass("text-violet-700"); + await expect.element(code.getByText("null", { exact: true })).toHaveClass("text-stone-500"); }); it("renders WHOIS text without JSON token classes", async () => { - const user = userEvent.setup(); - - render( + await render( { />, ); - await user.click(screen.getByRole("button", { name: "View raw WHOIS data" })); + await page.getByRole("button", { name: "View raw WHOIS data" }).click(); - const dialog = await screen.findByRole("dialog"); - const code = within(dialog).getByLabelText("Raw WHOIS data"); + await expect.element(page.getByRole("dialog")).toBeInTheDocument(); + const code = page.getByLabelText("Raw WHOIS data"); - expect(within(code).getByText(/Domain Name: EXAMPLE.COM/)).not.toHaveClass("text-blue-700"); - expect(within(code).queryByText('"ldhName"')).not.toBeInTheDocument(); + await expect + .element(code.getByText(/Domain Name: EXAMPLE.COM/)) + .not.toHaveClass("text-blue-700"); + await expect.element(code.getByText('"ldhName"', { exact: true })).not.toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/registration/registration-section.test.tsx b/apps/web/components/domain/registration/registration-section.test.tsx index 1106f8d6..f015d523 100644 --- a/apps/web/components/domain/registration/registration-section.test.tsx +++ b/apps/web/components/domain/registration/registration-section.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { RegistrationSection } from "./registration-section"; @@ -23,8 +24,8 @@ vi.mock("@/components/ui/tooltip", () => ({ })); describe("RegistrationSection", () => { - it("renders registrar and dates", () => { - render( + it("renders registrar and dates", async () => { + await render( { } />, ); - // Use getAllByText since provider name appears in multiple places (value + tooltip) - const namecheapElements = screen.getAllByText("Namecheap"); - expect(namecheapElements.length).toBeGreaterThan(0); + // Provider name appears in multiple places (value + tooltip) + expect(page.getByText("Namecheap", { exact: true }).length).toBeGreaterThan(0); }); - it("shows unavailable notice when status is unknown", () => { - render( + it("shows unavailable notice when status is unknown", async () => { + await render( { } />, ); - expect(screen.getByText(/Registration Data Unavailable/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Registration Data Unavailable/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/relative-age.test.tsx b/apps/web/components/domain/relative-age.test.tsx index 93170ccd..1ebfde91 100644 --- a/apps/web/components/domain/relative-age.test.tsx +++ b/apps/web/components/domain/relative-age.test.tsx @@ -1,19 +1,22 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; describe("RelativeAgeString", () => { it("renders an invisible placeholder before hydration", async () => { vi.resetModules(); + const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); const { resetHydratedNow } = await import("@/hooks/use-hydrated-now"); const { RelativeAgeString } = await import("./relative-age"); // Match the server and first client render: no clock yet. resetHydratedNow(null); - render(); + await render(); - expect(screen.getByText("(loading)")).toHaveClass("invisible"); + await expect.element(page.getByText("(loading)", { exact: true })).toHaveClass("invisible"); + raf.mockRestore(); }); it("renders the age from the shared clock after hydration", async () => { @@ -23,8 +26,8 @@ describe("RelativeAgeString", () => { resetHydratedNow(new Date("2025-01-01T00:00:00Z")); - render(); + await render(); - expect(await screen.findByText("(5 years ago)")).toBeInTheDocument(); + await expect.element(page.getByText("(5 years ago)", { exact: true })).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/relative-expiry.test.tsx b/apps/web/components/domain/relative-expiry.test.tsx index 0f9936c4..7b99afcc 100644 --- a/apps/web/components/domain/relative-expiry.test.tsx +++ b/apps/web/components/domain/relative-expiry.test.tsx @@ -1,18 +1,21 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; describe("RelativeExpiryString", () => { it("renders an invisible placeholder before hydration", async () => { vi.resetModules(); + const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); const { resetHydratedNow } = await import("@/hooks/use-hydrated-now"); const { RelativeExpiryString } = await import("./relative-expiry"); resetHydratedNow(null); - render(); + await render(); - expect(screen.getByText("(loading)")).toHaveClass("invisible"); + await expect.element(page.getByText("(loading)", { exact: true })).toHaveClass("invisible"); + raf.mockRestore(); }); it("renders the expiry from the shared clock after hydration", async () => { @@ -22,8 +25,8 @@ describe("RelativeExpiryString", () => { resetHydratedNow(new Date("2025-01-01T00:00:00Z")); - render(); + await render(); - expect(await screen.findByText("(in 1 year)")).toBeInTheDocument(); + await expect.element(page.getByText("(in 1 year)", { exact: true })).toBeInTheDocument(); }); }); diff --git a/apps/web/components/domain/seo/meta-tags-grid.test.tsx b/apps/web/components/domain/seo/meta-tags-grid.test.tsx index c5ad5b0f..e8e28e10 100644 --- a/apps/web/components/domain/seo/meta-tags-grid.test.tsx +++ b/apps/web/components/domain/seo/meta-tags-grid.test.tsx @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { MetaTagsGrid } from "./meta-tags-grid"; describe("MetaTagsGrid", () => { describe("basic rendering", () => { - it("renders all provided meta tags", () => { + it("renders all provided meta tags", async () => { const metaTagValues = [ { label: "Title", value: "Test Title" }, { label: "Description", value: "Test Description" }, @@ -17,73 +18,73 @@ describe("MetaTagsGrid", () => { { label: "Generator", value: "Next.js" }, { label: "Robots", value: "index, follow" }, ]; - render(); + await render(); // Use getAllByText for elements that appear multiple times (label + value) - expect(screen.getAllByText("Test Title").length).toBeGreaterThan(0); - expect(screen.getAllByText("Test Description").length).toBeGreaterThan(0); - expect(screen.getAllByText("seo, testing").length).toBeGreaterThan(0); - expect(screen.getAllByText("Test Author").length).toBeGreaterThan(0); - expect(screen.getAllByText("Next.js").length).toBeGreaterThan(0); - expect(screen.getAllByText("index, follow").length).toBeGreaterThan(0); + expect(page.getByText("Test Title", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("Test Description", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("seo, testing", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("Test Author", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("Next.js", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("index, follow", { exact: true }).length).toBeGreaterThan(0); }); - it("filters out null and undefined values", () => { + it("filters out null and undefined values", async () => { const metaTagValues = [ { label: "Title", value: "Test Title" }, { label: "Description", value: null }, { label: "Keywords", value: undefined }, { label: "Author", value: "Test Author" }, ]; - render(); + await render(); - expect(screen.getAllByText("Test Title").length).toBeGreaterThan(0); - expect(screen.getAllByText("Test Author").length).toBeGreaterThan(0); - expect(screen.queryByText("Description")).not.toBeInTheDocument(); - expect(screen.queryByText("Keywords")).not.toBeInTheDocument(); + expect(page.getByText("Test Title", { exact: true }).length).toBeGreaterThan(0); + expect(page.getByText("Test Author", { exact: true }).length).toBeGreaterThan(0); + await expect.element(page.getByText("Description", { exact: true })).not.toBeInTheDocument(); + await expect.element(page.getByText("Keywords", { exact: true })).not.toBeInTheDocument(); }); - it("displays correct count in subhead", () => { + it("displays correct count in subhead", async () => { const metaTagValues = [ { label: "Title", value: "Test Title" }, { label: "Description", value: "Test Description" }, { label: "Keywords", value: null }, ]; - render(); + await render(); // Should show count of 2 (only non-null values) - expect(screen.getByText("Meta Tags")).toBeInTheDocument(); + await expect.element(page.getByText("Meta Tags", { exact: true })).toBeInTheDocument(); // Count badge with "2" should be present - expect(screen.getByText("2")).toBeInTheDocument(); + await expect.element(page.getByText("2", { exact: true })).toBeInTheDocument(); }); - it("renders external link for URL values", () => { + it("renders external link for URL values", async () => { const metaTagValues = [ { label: "Canonical", value: "https://test.invalid/page" }, { label: "Image", value: "https://test.invalid/og-image.png" }, ]; - render(); + await render(); - const links = screen.getAllByRole("link"); + const links = page.getByRole("link").elements(); expect(links.length).toBeGreaterThan(0); const canonicalLink = links.find((link) => link.getAttribute("href")?.includes("test.invalid/page"), ); expect(canonicalLink).toBeDefined(); - expect(canonicalLink).toHaveAttribute("target", "_blank"); - expect(canonicalLink).toHaveAttribute("rel", "noopener"); + await expect.element(page.elementLocator(canonicalLink!)).toHaveAttribute("target", "_blank"); + await expect.element(page.elementLocator(canonicalLink!)).toHaveAttribute("rel", "noopener"); }); - it("does not render external link for non-URL values", () => { + it("does not render external link for non-URL values", async () => { const metaTagValues = [ { label: "Title", value: "Just a title" }, { label: "Author", value: "John Doe" }, ]; - render(); + await render(); // Should have no external links for these values - const links = screen.queryAllByRole("link"); + const links = page.getByRole("link").elements(); // Filter out any links that might be from external link icons const valueLinks = links.filter( (link) => @@ -154,16 +155,17 @@ describe("MetaTagsGrid", () => { ]; for (const testCase of testCases) { - it(`renders ${testCase.name}`, () => { - render(); + it(`renders ${testCase.name}`, async () => { + await render(); // Verify count - expect(screen.getByText(testCase.expectedCount.toString())).toBeInTheDocument(); + await expect + .element(page.getByText(testCase.expectedCount.toString(), { exact: true })) + .toBeInTheDocument(); // Use getAllByText to handle elements that appear in multiple places for (const expectedTag of testCase.expectedTags) { - const elements = screen.getAllByText(expectedTag); - expect(elements.length).toBeGreaterThan(0); + expect(page.getByText(expectedTag, { exact: true }).length).toBeGreaterThan(0); } }); } diff --git a/apps/web/components/domain/seo/robots-summary.test.tsx b/apps/web/components/domain/seo/robots-summary.test.tsx index 509b7412..6674c061 100644 --- a/apps/web/components/domain/seo/robots-summary.test.tsx +++ b/apps/web/components/domain/seo/robots-summary.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import type { SeoResponse } from "@domainstack/types"; import { RobotsSummary } from "./robots-summary"; @@ -38,7 +39,7 @@ vi.mock("@/components/ui/accordion", () => ({ describe("RobotsSummary", () => { describe("robots.txt rendering", () => { - it("renders robots.txt rules and sitemaps", () => { + it("renders robots.txt rules and sitemaps", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -52,48 +53,46 @@ describe("RobotsSummary", () => { ], sitemaps: ["https://test.invalid/sitemap.xml"], }; - render(); + await render(); // Verify robots.txt link - expect(screen.getByRole("link", { name: /robots.txt/i })).toHaveAttribute( - "href", - "https://test.invalid/robots.txt", - ); + await expect + .element(page.getByRole("link", { name: /robots.txt/i })) + .toHaveAttribute("href", "https://test.invalid/robots.txt"); // Verify rules are present (in accordion) - expect(screen.getByText("/admin")).toBeInTheDocument(); - expect(screen.getByText("/public")).toBeInTheDocument(); + await expect.element(page.getByText("/admin", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("/public", { exact: true })).toBeInTheDocument(); // Verify sitemap - expect(screen.getByRole("link", { name: /sitemap/i })).toHaveAttribute( - "href", - "https://test.invalid/sitemap.xml", - ); + await expect + .element(page.getByRole("link", { name: /sitemap/i })) + .toHaveAttribute("href", "https://test.invalid/sitemap.xml"); }); - it("shows empty state when robots.txt has empty groups", () => { + it("shows empty state when robots.txt has empty groups", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [], sitemaps: [], }; - render(); + await render(); // When there are no groups and no sitemaps, only the header with link is shown - expect(screen.getByRole("link", { name: /robots\.txt/i })).toBeInTheDocument(); + await expect.element(page.getByRole("link", { name: /robots\.txt/i })).toBeInTheDocument(); }); - it("shows appropriate message when robots.txt has no rules but has sitemaps", () => { + it("shows appropriate message when robots.txt has no rules but has sitemaps", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [], sitemaps: ["https://test.invalid/sitemap.xml", "https://test.invalid/sitemap-2.xml"], }; - render(); - expect(screen.getByText(/No crawl rules detected/i)).toBeInTheDocument(); - expect(screen.getByText("Sitemaps")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/No crawl rules detected/i)).toBeInTheDocument(); + await expect.element(page.getByText("Sitemaps", { exact: true })).toBeInTheDocument(); }); - it("handles multiple robot groups with different user agents", () => { + it("handles multiple robot groups with different user agents", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -108,13 +107,13 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); + await render(); // "All" appears in both the filter button and the user agent badge - expect(screen.getAllByText("All").length).toBeGreaterThan(0); - expect(screen.getByText("Googlebot")).toBeInTheDocument(); + expect(page.getByText("All", { exact: true }).length).toBeGreaterThan(0); + await expect.element(page.getByText("Googlebot", { exact: true })).toBeInTheDocument(); }); - it("renders crawl-delay rules", () => { + it("renders crawl-delay rules", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -128,11 +127,11 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); - expect(screen.getByText("10")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("10", { exact: true })).toBeInTheDocument(); }); - it("renders content-signal rules", () => { + it("renders content-signal rules", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -146,11 +145,11 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); - expect(screen.getByText("no-ai-training")).toBeInTheDocument(); + await render(); + await expect.element(page.getByText("no-ai-training", { exact: true })).toBeInTheDocument(); }); - it("renders multiple sitemaps", () => { + it("renders multiple sitemaps", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -165,23 +164,27 @@ describe("RobotsSummary", () => { "https://test.invalid/sitemap-blog.xml", ], }; - render(); + await render(); // Progressive reveal shows first 2 sitemaps by default - expect( - screen.getByRole("link", { - name: /https:\/\/test\.invalid\/sitemap\.xml/i, - }), - ).toBeInTheDocument(); - expect( - screen.getByRole("link", { - name: /https:\/\/test\.invalid\/sitemap-products\.xml/i, - }), - ).toBeInTheDocument(); + await expect + .element( + page.getByRole("link", { + name: /https:\/\/test\.invalid\/sitemap\.xml/i, + }), + ) + .toBeInTheDocument(); + await expect + .element( + page.getByRole("link", { + name: /https:\/\/test\.invalid\/sitemap-products\.xml/i, + }), + ) + .toBeInTheDocument(); // Third sitemap is hidden behind "Show more" button - expect(screen.getByRole("button", { name: /Show 1 more/i })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /Show 1 more/i })).toBeInTheDocument(); }); - it("handles empty disallow value (allow all)", () => { + it("handles empty disallow value (allow all)", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -192,14 +195,14 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); + await render(); // Empty disallow means allow all - the message appears inside the accordion when opened // Since we're using mocked accordions, we can't test the message visibility // Just verify the component renders - expect(screen.getByRole("link", { name: /robots\.txt/i })).toBeInTheDocument(); + await expect.element(page.getByRole("link", { name: /robots\.txt/i })).toBeInTheDocument(); }); - it("lists All bots first when grouped with other user agents", () => { + it("lists All bots first when grouped with other user agents", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -210,16 +213,16 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); + await render(); - const allBots = screen.getByText("All bots"); - const firstNamed = screen.getByText("AI2Bot"); + const allBots = page.getByText("All bots", { exact: true }).elements()[0]; + const firstNamed = page.getByText("AI2Bot", { exact: true }).elements()[0]; expect(allBots.compareDocumentPosition(firstNamed) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); }); - it("renders allow and disallow filter buttons", () => { + it("renders allow and disallow filter buttons", async () => { const robots: SeoResponse["robots"] = { fetched: true, groups: [ @@ -233,16 +236,16 @@ describe("RobotsSummary", () => { ], sitemaps: [], }; - render(); + await render(); // Get all buttons and find the filter buttons specifically - const buttons = screen.getAllByRole("button"); + const buttons = page.getByRole("button").elements(); const allButton = buttons.find((btn) => btn.textContent?.includes("All")); const allowButton = buttons.find((btn) => btn.textContent?.includes("Allow")); const disallowButton = buttons.find((btn) => btn.textContent?.includes("Disallow")); - expect(allButton).toBeInTheDocument(); - expect(allowButton).toBeInTheDocument(); - expect(disallowButton).toBeInTheDocument(); + expect(allButton).toBeDefined(); + expect(allowButton).toBeDefined(); + expect(disallowButton).toBeDefined(); }); }); }); diff --git a/apps/web/components/domain/seo/seo-section.test.tsx b/apps/web/components/domain/seo/seo-section.test.tsx index 850ee65b..e970ab80 100644 --- a/apps/web/components/domain/seo/seo-section.test.tsx +++ b/apps/web/components/domain/seo/seo-section.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import type { SeoResponse } from "@domainstack/types"; // Mock child components to isolate main component testing @@ -43,7 +44,7 @@ function buildSeoResponse(overrides: Partial = {}): SeoResponse { describe("SeoSection - Integration & Orchestration", () => { describe("component orchestration", () => { - it("renders all child components when data is present", () => { + it("renders all child components when data is present", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -68,21 +69,21 @@ describe("SeoSection - Integration & Orchestration", () => { sitemaps: ["https://test.invalid/sitemap.xml"], }, }); - render(); + await render(); - expect(screen.getByTestId("meta-tags-grid")).toBeInTheDocument(); - expect(screen.getByTestId("social-previews")).toBeInTheDocument(); - expect(screen.getByTestId("robots-summary")).toBeInTheDocument(); + await expect.element(page.getByTestId("meta-tags-grid")).toBeInTheDocument(); + await expect.element(page.getByTestId("social-previews")).toBeInTheDocument(); + await expect.element(page.getByTestId("robots-summary")).toBeInTheDocument(); }); - it("shows empty state when no meta tags", () => { + it("shows empty state when no meta tags", async () => { const data = buildSeoResponse(); - render(); - expect(screen.getByText(/No SEO meta detected/i)).toBeInTheDocument(); - expect(screen.queryByTestId("meta-tags-grid")).not.toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/No SEO meta detected/i)).toBeInTheDocument(); + await expect.element(page.getByTestId("meta-tags-grid")).not.toBeInTheDocument(); }); - it("does not render social preview tabs when preview is null", () => { + it("does not render social preview tabs when preview is null", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -101,16 +102,16 @@ describe("SeoSection - Integration & Orchestration", () => { sitemaps: [], }, }); - render(); + await render(); - expect(screen.getByTestId("meta-tags-grid")).toBeInTheDocument(); - expect(screen.queryByTestId("social-previews")).not.toBeInTheDocument(); - expect(screen.getByTestId("robots-summary")).toBeInTheDocument(); + await expect.element(page.getByTestId("meta-tags-grid")).toBeInTheDocument(); + await expect.element(page.getByTestId("social-previews")).not.toBeInTheDocument(); + await expect.element(page.getByTestId("robots-summary")).toBeInTheDocument(); }); }); describe("Twitter variant selection", () => { - it("selects large variant for summary_large_image card", () => { + it("selects large variant for summary_large_image card", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -125,12 +126,13 @@ describe("SeoSection - Integration & Orchestration", () => { canonicalUrl: "https://test.invalid", }, }); - render(); - const tabs = screen.getByTestId("social-previews"); - expect(tabs).toHaveAttribute("data-variant", "large"); + await render(); + await expect + .element(page.getByTestId("social-previews")) + .toHaveAttribute("data-variant", "large"); }); - it("selects compact variant for summary card", () => { + it("selects compact variant for summary card", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -145,12 +147,13 @@ describe("SeoSection - Integration & Orchestration", () => { canonicalUrl: "https://test.invalid", }, }); - render(); - const tabs = screen.getByTestId("social-previews"); - expect(tabs).toHaveAttribute("data-variant", "compact"); + await render(); + await expect + .element(page.getByTestId("social-previews")) + .toHaveAttribute("data-variant", "compact"); }); - it("defaults to large variant when image present but no twitter card", () => { + it("defaults to large variant when image present but no twitter card", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -165,12 +168,13 @@ describe("SeoSection - Integration & Orchestration", () => { canonicalUrl: "https://test.invalid", }, }); - render(); - const tabs = screen.getByTestId("social-previews"); - expect(tabs).toHaveAttribute("data-variant", "large"); + await render(); + await expect + .element(page.getByTestId("social-previews")) + .toHaveAttribute("data-variant", "large"); }); - it("defaults to compact variant when no image and no twitter card", () => { + it("defaults to compact variant when no image and no twitter card", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -185,14 +189,15 @@ describe("SeoSection - Integration & Orchestration", () => { canonicalUrl: "https://test.invalid", }, }); - render(); - const tabs = screen.getByTestId("social-previews"); - expect(tabs).toHaveAttribute("data-variant", "compact"); + await render(); + await expect + .element(page.getByTestId("social-previews")) + .toHaveAttribute("data-variant", "compact"); }); }); describe("redirect alert integration", () => { - it("shows alert when domain redirects to different domain", () => { + it("shows alert when domain redirects to different domain", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -211,11 +216,11 @@ describe("SeoSection - Integration & Orchestration", () => { status: 301, }, }); - render(); - expect(screen.getByText(/We followed a redirect/i)).toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/We followed a redirect/i)).toBeInTheDocument(); }); - it("does not show alert when no redirect occurred", () => { + it("does not show alert when no redirect occurred", async () => { const data = buildSeoResponse({ meta: { openGraph: {}, @@ -234,8 +239,8 @@ describe("SeoSection - Integration & Orchestration", () => { status: 200, }, }); - render(); - expect(screen.queryByText(/We followed a redirect/i)).not.toBeInTheDocument(); + await render(); + await expect.element(page.getByText(/We followed a redirect/i)).not.toBeInTheDocument(); }); }); }); diff --git a/apps/web/components/domain/seo/social-previews.test.tsx b/apps/web/components/domain/seo/social-previews.test.tsx index ff3077f3..2ae3fcde 100644 --- a/apps/web/components/domain/seo/social-previews.test.tsx +++ b/apps/web/components/domain/seo/social-previews.test.tsx @@ -1,7 +1,7 @@ -import { userEvent } from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen, within } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { SocialPreviews } from "./social-previews"; @@ -16,96 +16,87 @@ describe("SocialPreviews", () => { describe("tab switching", () => { it("switches between social preview providers", async () => { - const user = userEvent.setup(); - render(); + await render(); // Initial state: Twitter tab active - const twitterPreview = screen.getByRole("link", { + const twitterPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(twitterPreview).toHaveAttribute("data-provider", "twitter"); + await expect.element(twitterPreview).toHaveAttribute("data-provider", "twitter"); // Click Facebook tab - const facebookTab = screen.getByRole("tab", { name: /facebook/i }); - await user.click(facebookTab); - const facebookPreview = screen.getByRole("link", { + await page.getByRole("tab", { name: /facebook/i }).click(); + const facebookPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(facebookPreview).toHaveAttribute("data-provider", "facebook"); + await expect.element(facebookPreview).toHaveAttribute("data-provider", "facebook"); // Click LinkedIn tab - const linkedinTab = screen.getByRole("tab", { name: /linkedin/i }); - await user.click(linkedinTab); - const linkedinPreview = screen.getByRole("link", { + await page.getByRole("tab", { name: /linkedin/i }).click(); + const linkedinPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(linkedinPreview).toHaveAttribute("data-provider", "linkedin"); + await expect.element(linkedinPreview).toHaveAttribute("data-provider", "linkedin"); // Click Discord tab - const discordTab = screen.getByRole("tab", { name: /discord/i }); - await user.click(discordTab); - const discordPreview = screen.getByRole("link", { + await page.getByRole("tab", { name: /discord/i }).click(); + const discordPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(discordPreview).toHaveAttribute("data-provider", "discord"); + await expect.element(discordPreview).toHaveAttribute("data-provider", "discord"); // Click Slack tab - const slackTab = screen.getByRole("tab", { name: /slack/i }); - await user.click(slackTab); - const slackPreview = screen.getByRole("link", { + await page.getByRole("tab", { name: /slack/i }).click(); + const slackPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(slackPreview).toHaveAttribute("data-provider", "slack"); + await expect.element(slackPreview).toHaveAttribute("data-provider", "slack"); }); it("renders correct active tab content", async () => { - const user = userEvent.setup(); - render(); + await render(); // Check initial Twitter content - expect(screen.getByRole("link", { name: /open test.invalid in a new tab/i })).toHaveAttribute( - "data-provider", - "twitter", - ); + await expect + .element(page.getByRole("link", { name: /open test.invalid in a new tab/i })) + .toHaveAttribute("data-provider", "twitter"); // Switch to Facebook and verify - await user.click(screen.getByRole("tab", { name: /facebook/i })); - expect(screen.getByRole("link", { name: /open test.invalid in a new tab/i })).toHaveAttribute( - "data-provider", - "facebook", - ); + await page.getByRole("tab", { name: /facebook/i }).click(); + await expect + .element(page.getByRole("link", { name: /open test.invalid in a new tab/i })) + .toHaveAttribute("data-provider", "facebook"); }); }); describe("Twitter card variants", () => { - it("renders compact variant for Twitter when specified", () => { - render(); - const preview = screen.getByRole("link", { + it("renders compact variant for Twitter when specified", async () => { + await render(); + const preview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(preview).toHaveAttribute("data-provider", "twitter"); - expect(preview).toHaveAttribute("data-variant", "compact"); + await expect.element(preview).toHaveAttribute("data-provider", "twitter"); + await expect.element(preview).toHaveAttribute("data-variant", "compact"); }); - it("renders large variant for Twitter when specified", () => { - render(); - const preview = screen.getByRole("link", { + it("renders large variant for Twitter when specified", async () => { + await render(); + const preview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(preview).toHaveAttribute("data-provider", "twitter"); - expect(preview).toHaveAttribute("data-variant", "large"); + await expect.element(preview).toHaveAttribute("data-provider", "twitter"); + await expect.element(preview).toHaveAttribute("data-variant", "large"); }); it("does not apply variant to non-Twitter providers", async () => { - const user = userEvent.setup(); - render(); + await render(); // Switch to Facebook - should not have variant attribute or should not be "large" - await user.click(screen.getByRole("tab", { name: /facebook/i })); - const facebookPreview = screen.getByRole("link", { + await page.getByRole("tab", { name: /facebook/i }).click(); + const facebookPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(facebookPreview).toHaveAttribute("data-provider", "facebook"); + await expect.element(facebookPreview).toHaveAttribute("data-provider", "facebook"); // Facebook doesn't use the twitterVariant prop }); }); @@ -124,7 +115,6 @@ describe("SocialPreviews", () => { for (const { provider, tabName } of providers) { it(`renders ${provider} preview correctly`, async () => { - const user = userEvent.setup(); const preview = { title: `${provider} Preview Title`, description: `${provider} Preview Description`, @@ -132,21 +122,19 @@ describe("SocialPreviews", () => { imageUploaded: `https://test.invalid/${provider}-uploaded.png`, canonicalUrl: "https://test.invalid", }; - render(); + await render(); // Switch to the provider's tab - const tab = screen.getByRole("tab", { name: new RegExp(tabName, "i") }); - await user.click(tab); + await page.getByRole("tab", { name: new RegExp(tabName, "i") }).click(); // Verify the preview is rendered with correct provider - const previewLink = screen.getByRole("link", { + const previewLink = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(previewLink).toHaveAttribute("data-provider", provider); + await expect.element(previewLink).toHaveAttribute("data-provider", provider); }); it(`renders ${provider} preview without image`, async () => { - const user = userEvent.setup(); const preview = { title: `${provider} No Image`, description: `${provider} description without image`, @@ -154,23 +142,22 @@ describe("SocialPreviews", () => { imageUploaded: null, canonicalUrl: "https://test.invalid", }; - render(); + await render(); - const tab = screen.getByRole("tab", { name: new RegExp(tabName, "i") }); - await user.click(tab); + await page.getByRole("tab", { name: new RegExp(tabName, "i") }).click(); - const previewLink = screen.getByRole("link", { + const previewLink = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(previewLink).toHaveAttribute("data-provider", provider); + await expect.element(previewLink).toHaveAttribute("data-provider", provider); // Verify "No image" accessible text is present in the DOM - const previewContainer = within(previewLink); - expect(previewContainer.getByText("No image")).toBeInTheDocument(); + await expect + .element(previewLink.getByText("No image", { exact: true })) + .toBeInTheDocument(); }); } it("uses imageUploaded when available", async () => { - const user = userEvent.setup(); const uploadedImageUrl = "https://test.invalid/uploaded-image.png"; const preview = { title: "Uploaded Image Preview", @@ -179,22 +166,23 @@ describe("SocialPreviews", () => { imageUploaded: uploadedImageUrl, canonicalUrl: "https://test.invalid", }; - render(); + await render(); // Twitter preview should be visible by default - const twitterPreview = screen.getByRole("link", { + const twitterPreview = page.getByRole("link", { name: /open test.invalid in a new tab/i, }); - expect(twitterPreview).toBeInTheDocument(); + await expect.element(twitterPreview).toBeInTheDocument(); // Switch to Facebook to verify imageUploaded is used - await user.click(screen.getByRole("tab", { name: /facebook/i })); - const facebookPreview = screen.getByRole("link", { - name: /open test.invalid in a new tab/i, - }); + await page.getByRole("tab", { name: /facebook/i }).click(); + await expect + .element(page.getByRole("link", { name: /open test.invalid in a new tab/i })) + .toHaveAttribute("data-provider", "facebook"); // Check that the preview image element uses the uploaded URL - const image = within(facebookPreview).getByAltText("Preview image"); - expect(image).toHaveAttribute("src", expect.stringContaining("uploaded")); + await expect + .element(page.getByAltText("Preview image")) + .toHaveAttribute("src", expect.stringContaining("uploaded")); }); }); }); diff --git a/apps/web/components/domain/track-domain-button.test.tsx b/apps/web/components/domain/track-domain-button.test.tsx index 5be9109b..a17c22f5 100644 --- a/apps/web/components/domain/track-domain-button.test.tsx +++ b/apps/web/components/domain/track-domain-button.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; const nav = vi.hoisted(() => ({ push: vi.fn<(href: string, opts?: { scroll?: boolean }) => void | Promise>(), @@ -23,11 +23,11 @@ vi.mock("@/lib/trpc/client", async () => { import { makeTrackedDomain } from "@/components/dashboard/test-fixtures"; import { TrackDomainButton } from "@/components/domain/track-domain-button"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { resetTrpcMocks, setDomainsState } from "@/mocks/trpc"; import { TooltipProvider } from "@domainstack/ui/tooltip"; -function renderButton(domain = "example.com") { +async function renderButton(domain = "example.com") { return render( @@ -57,25 +57,23 @@ describe("TrackDomainButton", () => { ); setDomainsState([]); - renderButton(); + await renderButton(); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Track domain" })).toBeEnabled(); - }); - await userEvent.click(screen.getByRole("button", { name: "Track domain" })); + await expect.element(page.getByRole("button", { name: "Track domain" })).toBeEnabled(); + await page.getByRole("button", { name: "Track domain" }).click(); expect(nav.push).toHaveBeenCalledWith("/dashboard/add-domain?domain=example.com", { scroll: false, }); - await waitFor(() => { - const button = screen.getByRole("button", { name: "Track domain" }); - expect(button).toBeDisabled(); - expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument(); - expect(button.querySelectorAll("svg")).toHaveLength(1); + await vi.waitFor(async () => { + const button = page.getByRole("button", { name: "Track domain" }); + await expect.element(button).toBeDisabled(); + await expect.element(page.getByRole("status", { name: /loading/i })).toBeInTheDocument(); + expect(button.elements()[0].querySelectorAll("svg")).toHaveLength(1); }); finishNavigation?.(); - await waitFor(() => expect(screen.getByRole("button", { name: "Track domain" })).toBeEnabled()); + await expect.element(page.getByRole("button", { name: "Track domain" })).toBeEnabled(); }); it("shows a pending state while resuming verification", async () => { @@ -96,26 +94,24 @@ describe("TrackDomainButton", () => { verificationStatus: "unverified", }), ]); - renderButton(); + await renderButton(); - const button = await screen.findByRole("button", { name: "Verify domain" }); - await waitFor(() => expect(button).toBeEnabled()); - await userEvent.click(button); + await expect.element(page.getByRole("button", { name: "Verify domain" })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Verify domain" })).toBeEnabled(); + await page.getByRole("button", { name: "Verify domain" }).click(); expect(nav.push).toHaveBeenCalledWith( "/dashboard/add-domain?resume=true&id=domain-pending&method=dns_txt", { scroll: false }, ); - await waitFor(() => { - const pendingButton = screen.getByRole("button", { name: "Verify domain" }); - expect(pendingButton).toBeDisabled(); - expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument(); - expect(pendingButton.querySelectorAll("svg")).toHaveLength(1); + await vi.waitFor(async () => { + const pendingButton = page.getByRole("button", { name: "Verify domain" }); + await expect.element(pendingButton).toBeDisabled(); + await expect.element(page.getByRole("status", { name: /loading/i })).toBeInTheDocument(); + expect(pendingButton.elements()[0].querySelectorAll("svg")).toHaveLength(1); }); finishNavigation?.(); - await waitFor(() => - expect(screen.getByRole("button", { name: "Verify domain" })).toBeEnabled(), - ); + await expect.element(page.getByRole("button", { name: "Verify domain" })).toBeEnabled(); }); }); diff --git a/apps/web/components/notifications/notifications-popover.test.tsx b/apps/web/components/notifications/notifications-popover.test.tsx index ae2cf6d9..273a8127 100644 --- a/apps/web/components/notifications/notifications-popover.test.tsx +++ b/apps/web/components/notifications/notifications-popover.test.tsx @@ -1,5 +1,5 @@ -import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; const nav = vi.hoisted(() => ({ push: vi.fn<(href: string) => void>(), @@ -26,7 +26,7 @@ import { makeNotificationsInfiniteData, } from "@/components/notifications/test-fixtures"; import { resetHydratedNow } from "@/hooks/use-hydrated-now"; -import { createTestQueryClient, render, screen, waitFor, within } from "@/mocks/react"; +import { createTestQueryClient, render } from "@/mocks/react"; import { listNotificationsQuery, markAllReadMutation, @@ -70,19 +70,17 @@ function seedNotifications( queryClient.setQueryData(notificationsListQueryKey("read"), makeNotificationsInfiniteData(read)); } -function renderPopover(items: NotificationData[] = [unreadAlpha, unreadGeneric, archivedGamma]) { +async function renderPopover( + items: NotificationData[] = [unreadAlpha, unreadGeneric, archivedGamma], +) { const queryClient = createTestQueryClient(); seedNotifications(queryClient, items); return render(, { queryClient }); } -function setupUser() { - return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); -} - -async function openInbox(user: ReturnType) { - await user.click(screen.getByRole("button", { name: /Notifications/ })); - expect(await screen.findByRole("heading", { name: "Notifications" })).toBeInTheDocument(); +async function openInbox() { + await page.getByRole("button", { name: /Notifications/ }).click(); + await expect.element(page.getByRole("heading", { name: "Notifications" })).toBeInTheDocument(); } describe("NotificationsPopover", () => { @@ -101,143 +99,147 @@ describe("NotificationsPopover", () => { }); it("shows a badge on the bell when there are unread notifications", async () => { - renderPopover([unreadAlpha]); + await renderPopover([unreadAlpha]); - const bell = await screen.findByRole("button", { name: "Notifications (1)" }); - expect(bell.querySelector(".bg-destructive")).not.toBeNull(); + const bell = page.getByRole("button", { name: "Notifications (1)" }); + await expect.element(bell).toBeInTheDocument(); + expect(bell.element().querySelector(".bg-destructive")).not.toBeNull(); }); it("hides the badge when there are no unread notifications", async () => { - renderPopover([]); + await renderPopover([]); - const bell = await screen.findByRole("button", { name: "Notifications" }); - expect(bell.querySelector(".bg-destructive")).toBeNull(); + const bell = page.getByRole("button", { name: "Notifications" }); + await expect.element(bell).toBeInTheDocument(); + expect(bell.element().querySelector(".bg-destructive")).toBeNull(); }); it("opens the inbox with unread copy and a relative timestamp", async () => { - const user = setupUser(); - renderPopover([unreadAlpha]); - await openInbox(user); + await renderPopover([unreadAlpha]); + await openInbox(); - expect(screen.getByText("alpha.com expires in 7 days")).toBeInTheDocument(); - expect(screen.getByRole("status", { name: "Unread" })).toBeInTheDocument(); - expect(screen.getByText("1 day ago")).toBeInTheDocument(); + await expect + .element(page.getByText("alpha.com expires in 7 days", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByRole("status", { name: "Unread" })).toBeInTheDocument(); + await expect.element(page.getByText("1 day ago", { exact: true })).toBeInTheDocument(); }); it("shows distinct empty copy for inbox and archive", async () => { - const user = setupUser(); - renderPopover([]); - await openInbox(user); + await renderPopover([]); + await openInbox(); - expect(screen.getByText("All caught up!")).toBeInTheDocument(); - expect(screen.getByText("No unread notifications")).toBeInTheDocument(); + await expect.element(page.getByText("All caught up!", { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByText("No unread notifications", { exact: true })) + .toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: /Archive/ })); - expect(await screen.findByText("Nothing archived yet")).toBeInTheDocument(); - expect(screen.getByText("Nothing to see here (yet…)")).toBeInTheDocument(); - expect(screen.queryByText("All caught up!")).not.toBeInTheDocument(); + await page.getByRole("tab", { name: /Archive/ }).click(); + await expect + .element(page.getByText("Nothing archived yet", { exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByText("Nothing to see here (yet…)", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText("All caught up!", { exact: true })).not.toBeInTheDocument(); }); it("shows an error when the list fails to load", async () => { - const user = setupUser(); listNotificationsQuery.mockRejectedValue(new Error("nope")); const queryClient = createTestQueryClient(); setNotificationsState([unreadAlpha]); queryClient.setQueryData(NOTIFICATIONS_UNREAD_COUNT_QUERY_KEY, 1); - render(, { queryClient }); + await render(, { queryClient }); - await openInbox(user); + await openInbox(); - expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load notifications"); + await expect.element(page.getByRole("alert")).toHaveTextContent("Failed to load notifications"); }); it("deep-links domain notifications and falls back to the dashboard", async () => { - const user = setupUser(); - renderPopover([unreadAlpha, unreadGeneric]); - await openInbox(user); + await renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(); - expect(screen.getByRole("link", { name: /alpha.com expires in 7 days/ })).toHaveAttribute( - "href", - "/dashboard?domainId=domain-alpha", - ); - expect(screen.getByRole("link", { name: /DNS provider changed/ })).toHaveAttribute( - "href", - "/dashboard", - ); + await expect + .element(page.getByRole("link", { name: /alpha.com expires in 7 days/ })) + .toHaveAttribute("href", "/dashboard?domainId=domain-alpha"); + await expect + .element(page.getByRole("link", { name: /DNS provider changed/ })) + .toHaveAttribute("href", "/dashboard"); }); it("marks only the clicked notification as read", async () => { - const user = setupUser(); - renderPopover([unreadAlpha, unreadGeneric]); - await openInbox(user); + await renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(); - const notificationLink = screen.getByRole("link", { name: /alpha.com expires in 7 days/ }); - notificationLink.addEventListener("click", (event) => event.preventDefault(), true); - await user.click(notificationLink); + const notificationLink = page.getByRole("link", { name: /alpha.com expires in 7 days/ }); + notificationLink.element().addEventListener("click", (event) => event.preventDefault(), true); + await notificationLink.click(); - await waitFor(() => { + await vi.waitFor(() => { expect(markReadMutation.mock.calls[0]?.[0]).toEqual({ id: "notif-alpha" }); }); expect(markAllReadMutation).not.toHaveBeenCalled(); }); it("clears all unread notifications from Inbox", async () => { - const user = setupUser(); - renderPopover([unreadAlpha, unreadGeneric]); - await openInbox(user); + await renderPopover([unreadAlpha, unreadGeneric]); + await openInbox(); - await user.click(screen.getByRole("button", { name: "Clear all notifications" })); + await page.getByRole("button", { name: "Clear all notifications" }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(markAllReadMutation).toHaveBeenCalledOnce(); }); - expect(await screen.findByText("All caught up!")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); + await expect.element(page.getByText("All caught up!", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); }); it("marks remaining unread as read when switching to Archive", async () => { - const user = setupUser(); - renderPopover([unreadAlpha, archivedGamma]); - await openInbox(user); + await renderPopover([unreadAlpha, archivedGamma]); + await openInbox(); - await user.click(screen.getByRole("tab", { name: /Archive/ })); + await page.getByRole("tab", { name: /Archive/ }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(markAllReadMutation).toHaveBeenCalledOnce(); }); - expect(await screen.findByText("alpha.com expires in 7 days")).toBeInTheDocument(); - expect(screen.getByText("gamma.com expired")).toBeInTheDocument(); + await expect + .element(page.getByText("alpha.com expires in 7 days", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByText("gamma.com expired", { exact: true })).toBeInTheDocument(); }); it("marks remaining unread as read when closing Inbox", async () => { - const user = setupUser(); - renderPopover([unreadAlpha]); - await openInbox(user); + await renderPopover([unreadAlpha]); + await openInbox(); - await user.click(screen.getByRole("button", { name: /Notifications/ })); + await page.getByRole("button", { name: /Notifications/ }).click(); - await waitFor(() => { + await vi.waitFor(() => { expect(markAllReadMutation).toHaveBeenCalledOnce(); }); - expect(screen.queryByRole("heading", { name: "Notifications" })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Notifications" })) + .not.toBeInTheDocument(); }); it("closes and navigates to settings", async () => { - const user = setupUser(); - renderPopover([unreadAlpha]); - await openInbox(user); + await renderPopover([unreadAlpha]); + await openInbox(); - await user.click(screen.getByRole("button", { name: "Notification settings" })); + await page.getByRole("button", { name: "Notification settings" }).click(); expect(nav.push).toHaveBeenCalledWith("/settings/notifications"); - await waitFor(() => { + await vi.waitFor(() => { expect(markAllReadMutation).toHaveBeenCalledOnce(); }); - expect(screen.queryByRole("heading", { name: "Notifications" })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Notifications" })) + .not.toBeInTheDocument(); }); it("caps the inbox badge at 99+", async () => { - const user = setupUser(); unreadCountQuery.mockResolvedValue(100); const queryClient = createTestQueryClient(); setNotificationsState([unreadAlpha]); @@ -247,10 +249,14 @@ describe("NotificationsPopover", () => { makeNotificationsInfiniteData([unreadAlpha]), ); queryClient.setQueryData(notificationsListQueryKey("read"), makeNotificationsInfiniteData([])); - render(, { queryClient }); + await render(, { queryClient }); - expect(await screen.findByRole("button", { name: "Notifications (100)" })).toBeInTheDocument(); - await openInbox(user); - expect(within(screen.getByRole("tab", { name: /Inbox/ })).getByText("99+")).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Notifications (100)" })) + .toBeInTheDocument(); + await openInbox(); + await expect + .element(page.getByRole("tab", { name: /Inbox/ }).getByText("99+", { exact: true })) + .toBeInTheDocument(); }); }); diff --git a/apps/web/components/search/header-search-client.test.tsx b/apps/web/components/search/header-search-client.test.tsx index 48c870a1..4f17e34a 100644 --- a/apps/web/components/search/header-search-client.test.tsx +++ b/apps/web/components/search/header-search-client.test.tsx @@ -1,7 +1,7 @@ -import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { HeaderSearchClient } from "./header-search-client"; @@ -19,24 +19,9 @@ vi.mock("next/navigation", () => ({ useSelectedLayoutSegment: () => "domain", })); -// Mock base-ui Form to avoid React instance mismatch in browser tests -vi.mock("@/components/ui/form", () => ({ - Form: ({ - children, - onFormSubmit, - ...props - }: React.ComponentProps<"form"> & { onFormSubmit?: () => void }) => ( -
{ - e.preventDefault(); - onFormSubmit?.(); - }} - > - {children} -
- ), -})); +function domainSearchInput() { + return page.getByRole("textbox", { name: "Domain" }); +} describe("HeaderSearch", () => { beforeEach(() => { @@ -45,17 +30,17 @@ describe("HeaderSearch", () => { it("prefills normalized domain from params and navigates on Enter", async () => { nav.params = { domain: "Sub.Test.INVALID" }; - render(); - const input = screen.getByLabelText(/Search any domain/i); - expect(input).toHaveValue("sub.test.invalid"); + await render(); + const input = domainSearchInput(); + await expect.element(input).toHaveValue("sub.test.invalid"); await userEvent.type(input, "{Enter}"); expect(nav.push).toHaveBeenCalledWith("/sub.test.invalid"); }); it("does nothing on invalid domain", async () => { nav.params = { domain: "invalid domain" }; - render(); - const input = screen.getByLabelText(/Search any domain/i); + await render(); + const input = domainSearchInput(); await userEvent.type(input, "{Enter}"); expect(nav.push).not.toHaveBeenCalled(); }); @@ -70,15 +55,15 @@ describe("HeaderSearch", () => { ); nav.params = { domain: "foo.invalid" }; - const { rerender } = render(); - const input = screen.getByLabelText(/Search any domain/i); + const { rerender } = await render(); + const input = domainSearchInput(); // Submit to trigger loading state (disables input) await userEvent.type(input, "{Enter}"); - expect(input).toBeDisabled(); + await expect.element(input).toBeDisabled(); // Simulate navigation by changing route params and re-rendering nav.params = { domain: "bar.invalid" }; - rerender(); + await rerender(); finishNavigation?.(); - await waitFor(() => expect(screen.getByLabelText(/Search any domain/i)).not.toBeDisabled()); + await expect.element(domainSearchInput()).toBeEnabled(); }); }); diff --git a/apps/web/components/search/home-search-suggestions-client.test.tsx b/apps/web/components/search/home-search-suggestions-client.test.tsx index 45f15a83..ddc80889 100644 --- a/apps/web/components/search/home-search-suggestions-client.test.tsx +++ b/apps/web/components/search/home-search-suggestions-client.test.tsx @@ -1,8 +1,8 @@ -import userEvent from "@testing-library/user-event"; import { createElement } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; -import { render, screen } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { HomeSearchSuggestionsClient } from "./home-search-suggestions-client"; @@ -56,50 +56,55 @@ describe("DomainSuggestionsClient", () => { }); it("renders provided suggestions when there is no history", async () => { - render(); + await render(); // Wait for a known suggestion like jarv.invalid to appear - expect(await screen.findByRole("button", { name: /jarv\.invalid/i })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /jarv\.invalid/i })).toBeInTheDocument(); // At least one favicon placeholder should exist expect(document.querySelectorAll('[data-slot="favicon"]').length).toBeGreaterThan(0); }); it("renders no suggestions when defaultSuggestions is empty and no history", async () => { - render(); + await render(); // Container should render but with no buttons - const buttons = screen.queryAllByRole("button"); - expect(buttons.length).toBe(0); + expect(page.getByRole("button").length).toBe(0); }); it("merges history and suggestions without duplicates, capped by max", async () => { mockHistoryState.history = ["foo.invalid", "github.invalid", "bar.invalid"]; - render(); + await render( + , + ); // History entries appear - expect(await screen.findByRole("button", { name: /foo\.invalid/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /bar\.invalid/i })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /foo\.invalid/i })).toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /bar\.invalid/i })).toBeInTheDocument(); // github.invalid appears only once (deduped with suggestions) - expect(screen.getAllByRole("button", { name: /github\.invalid/i }).length).toBe(1); + expect(page.getByRole("button", { name: /github\.invalid/i }).length).toBe(1); }); it("shows only history when defaultSuggestions is empty", async () => { mockHistoryState.history = ["example.invalid", "test.invalid"]; - render(); + await render(); // History entries appear - expect(await screen.findByRole("button", { name: /example\.invalid/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /test\.invalid/i })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: /example\.invalid/i })) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: /test\.invalid/i })).toBeInTheDocument(); // Should show 2 history items + 1 clear history button - expect(screen.getAllByRole("button").length).toBe(3); - expect(screen.getByRole("button", { name: /clear history/i })).toBeInTheDocument(); + expect(page.getByRole("button").length).toBe(3); + await expect.element(page.getByRole("button", { name: /clear history/i })).toBeInTheDocument(); }); it("clears history when clear button is clicked", async () => { mockHistoryState.history = ["example.invalid"]; - render(); + await render(); // Ensure history is loaded and rendered - expect(await screen.findByRole("button", { name: /example\.invalid/i })).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: /example\.invalid/i })) + .toBeInTheDocument(); - const clearButton = screen.getByRole("button", { name: /clear history/i }); - await userEvent.click(clearButton); + const clearButton = page.getByRole("button", { name: /clear history/i }); + await clearButton.click(); // Verify clearHistory was called expect(mockClearHistory).toHaveBeenCalled(); @@ -107,8 +112,8 @@ describe("DomainSuggestionsClient", () => { it("sets pending domain when a suggestion is clicked", async () => { mockHistoryState.history = ["example.invalid"]; - render(); - await userEvent.click(screen.getByRole("button", { name: /example.invalid/i })); + await render(); + await page.getByRole("button", { name: /example.invalid/i }).click(); expect(mockSetPendingDomain).toHaveBeenCalledWith("example.invalid"); }); }); diff --git a/apps/web/components/search/search-client.test.tsx b/apps/web/components/search/search-client.test.tsx index b3bc5fd5..a0679272 100644 --- a/apps/web/components/search/search-client.test.tsx +++ b/apps/web/components/search/search-client.test.tsx @@ -1,30 +1,11 @@ -import userEvent from "@testing-library/user-event"; import { Activity, useEffect, useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; -import { render, screen, waitFor } from "@/mocks/react"; +import { render } from "@/mocks/react"; import { SearchClient } from "./search-client"; -// Mock base-ui Form to avoid React instance mismatch in browser tests -vi.mock("@domainstack/ui/form", () => ({ - Form: ({ - children, - onFormSubmit, - ...props - }: React.ComponentProps<"form"> & { onFormSubmit?: () => void }) => ( -
{ - e.preventDefault(); - onFormSubmit?.(); - }} - > - {children} -
- ), -})); - const nav = vi.hoisted(() => ({ push: vi.fn<(href: string) => void | Promise>(), })); @@ -60,6 +41,10 @@ vi.mock("next/navigation", () => ({ vi.mock("sonner", () => ({ toast: { error: vi.fn<(message?: string) => void>() } })); +function domainSearchInput() { + return page.getByRole("textbox", { name: "Domain" }); +} + describe("DomainSearch (form variant)", () => { beforeEach(() => { nav.push.mockClear(); @@ -71,18 +56,20 @@ describe("DomainSearch (form variant)", () => { useIsMobile.mockReturnValue(false); }); - it("exposes WebMCP tool attributes for domain search", () => { - render(); + it("exposes WebMCP tool attributes for domain search", async () => { + await render(); - const form = screen.getByRole("form", { name: "Domain search" }); - expect(form).toHaveAttribute("tool-name", "domain-search"); - expect(form).toHaveAttribute( - "tool-description", - "Look up WHOIS, DNS, SSL, hosting, HTTP headers, and SEO for any domain", - ); - expect(form).toHaveAttribute("action", "/"); - expect(form).toHaveAttribute("method", "GET"); - expect(screen.getByLabelText(/Search any domain/i)).toHaveAttribute("name", "q"); + const form = page.getByRole("form", { name: "Domain search" }); + await expect.element(form).toHaveAttribute("tool-name", "domain-search"); + await expect + .element(form) + .toHaveAttribute( + "tool-description", + "Look up WHOIS, DNS, SSL, hosting, HTTP headers, and SEO for any domain", + ); + await expect.element(form).toHaveAttribute("action", "/"); + await expect.element(form).toHaveAttribute("method", "GET"); + await expect.element(domainSearchInput()).toHaveAttribute("name", "q"); }); it("submits valid domain and navigates", async () => { @@ -94,19 +81,19 @@ describe("DomainSearch (form variant)", () => { }), ); - render(); - const input = screen.getByLabelText(/Search any domain/i); + await render(); + const input = domainSearchInput(); await userEvent.type(input, "test.invalid{Enter}"); expect(nav.push).toHaveBeenCalledWith("/test.invalid"); // Input and button should be disabled while loading/submitting - expect(screen.getByLabelText(/Search any domain/i)).toBeDisabled(); + await expect.element(domainSearchInput()).toBeDisabled(); // Submit button shows a loading spinner that replaces the submit icon - const submitButton = screen.getByRole("button", { name: /loading/i }); - expect(submitButton).toBeDisabled(); - expect(submitButton.querySelectorAll("svg")).toHaveLength(1); + const submitButton = page.getByRole("button", { name: /loading/i }); + await expect.element(submitButton).toBeDisabled(); + expect(submitButton.element().querySelectorAll("svg")).toHaveLength(1); finishNavigation?.(); - await waitFor(() => expect(input).toBeEnabled()); + await expect.element(input).toBeEnabled(); }); it("clears the loading state when a preserved homepage is restored", async () => { @@ -132,22 +119,22 @@ describe("DomainSearch (form variant)", () => { ); } - render(); - const input = screen.getByLabelText(/Search any domain/i); + await render(); + const input = domainSearchInput(); await userEvent.type(input, "test.invalid{Enter}"); - await userEvent.click(screen.getByRole("button", { name: "Return home" })); + await page.getByRole("button", { name: "Return home" }).click(); - expect(screen.getByLabelText(/Search any domain/i)).toBeEnabled(); - expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument(); + await expect.element(domainSearchInput()).toBeEnabled(); + await expect.element(page.getByRole("status", { name: /loading/i })).not.toBeInTheDocument(); }); it("shows error toast for invalid domain", async () => { const { toast } = (await import("sonner")) as unknown as { toast: { error: (msg: string) => void }; }; - render(); - const input = screen.getByLabelText(/Search any domain/i); + await render(); + const input = domainSearchInput(); await userEvent.type(input, "not a domain{Enter}"); expect(toast.error).toHaveBeenCalled(); }); @@ -155,18 +142,18 @@ describe("DomainSearch (form variant)", () => { it("handles pending domain from store (suggestion click)", async () => { // Start with no pending domain mockPendingDomain.value = null; - const { rerender } = render(); + const { rerender } = await render(); // Simulate external navigation request (e.g., from suggestion click via store) mockPendingDomain.value = "test.invalid"; - rerender(); + await rerender(); // Wait for input to reflect the triggered domain (async due to useEffect) - const input = await screen.findByLabelText(/Search any domain/i); - expect(input).toHaveValue("test.invalid"); + const input = domainSearchInput(); + await expect.element(input).toHaveValue("test.invalid"); // Wait for navigation and store clear to be triggered - await waitFor(() => { + await vi.waitFor(() => { expect(nav.push).toHaveBeenCalledWith("/test.invalid"); expect(mockSetPendingDomain).toHaveBeenCalledWith(null); }); @@ -180,53 +167,51 @@ describe("DomainSearch (header variant)", () => { }); it("focuses the input on Mod+K", async () => { - render(); + await render(); - const input = screen.getByLabelText(/Search any domain/i); - input.blur(); - expect(input).not.toHaveFocus(); + const input = domainSearchInput(); + input.element().blur(); + expect(document.activeElement).not.toBe(input.element()); const isMac = /mac/i.test(navigator.userAgent); await userEvent.keyboard(isMac ? "{Meta>}k{/Meta}" : "{Control>}k{/Control}"); - expect(input).toHaveFocus(); + expect(document.activeElement).toBe(input.element()); }); it("shows full placeholder on desktop screens", async () => { useIsMobile.mockReturnValue(false); - render(); + await render(); - const input = screen.getByLabelText(/Search any domain/i); - expect(input).toHaveAttribute("placeholder", "Search any domain\u2026"); + const input = domainSearchInput(); + await expect.element(input).toHaveAttribute("placeholder", "Search any domain\u2026"); }); it("shows short placeholder on mobile screens", async () => { useIsMobile.mockReturnValue(true); - render(); + await render(); - const input = screen.getByLabelText(/Search any domain/i); - await waitFor(() => { - expect(input).toHaveAttribute("placeholder", "Search\u2026"); - }); + const input = domainSearchInput(); + await expect.element(input).toHaveAttribute("placeholder", "Search\u2026"); }); it("updates placeholder when window is resized", async () => { // Start with desktop useIsMobile.mockReturnValue(false); - const { rerender } = render(); + const { rerender } = await render(); // Verify desktop placeholder - let input = screen.getByLabelText(/Search any domain/i); - expect(input).toHaveAttribute("placeholder", "Search any domain\u2026"); + let input = domainSearchInput(); + await expect.element(input).toHaveAttribute("placeholder", "Search any domain\u2026"); // Simulate resize to mobile useIsMobile.mockReturnValue(true); - rerender(); + await rerender(); // Verify mobile placeholder - input = screen.getByLabelText(/Search any domain/i); - expect(input).toHaveAttribute("placeholder", "Search\u2026"); + input = domainSearchInput(); + await expect.element(input).toHaveAttribute("placeholder", "Search\u2026"); }); }); diff --git a/apps/web/components/ui/map.tsx b/apps/web/components/ui/map.tsx index e2525d18..6d8a6148 100644 --- a/apps/web/components/ui/map.tsx +++ b/apps/web/components/ui/map.tsx @@ -477,7 +477,7 @@ function MapMarkerPopup({ return createPortal(
@@ -571,7 +571,7 @@ function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerToo return createPortal(
@@ -918,7 +918,7 @@ function MapPopup({ return createPortal(
diff --git a/apps/web/hooks/use-calendar-feed.test.tsx b/apps/web/hooks/use-calendar-feed.test.tsx index d12e1f94..e4984e0c 100644 --- a/apps/web/hooks/use-calendar-feed.test.tsx +++ b/apps/web/hooks/use-calendar-feed.test.tsx @@ -14,7 +14,7 @@ vi.mock("sonner", () => ({ }, })); -import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { createTestQueryClient, renderHook } from "@/mocks/react"; import { CALENDAR_FEED_QUERY_KEY, CALENDAR_FEED_ROTATED_URL, @@ -40,12 +40,12 @@ function getFeed(queryClient: ReturnType) { return queryClient.getQueryData(CALENDAR_FEED_QUERY_KEY); } -function renderCalendarFeed(feed: CalendarFeedData = { enabled: false }) { +async function renderCalendarFeed(feed: CalendarFeedData = { enabled: false }) { const queryClient = createTestQueryClient(); setCalendarFeedState(feed); queryClient.setQueryData(CALENDAR_FEED_QUERY_KEY, feed); - const view = renderHook(() => useCalendarFeed(), { + const view = await renderHook(() => useCalendarFeed(), { wrapper: ({ children }) => ( {children} ), @@ -66,11 +66,11 @@ describe("useCalendarFeed", () => { }); it("enables the feed and writes the new URL into cache", async () => { - const { result, queryClient } = renderCalendarFeed(); + const { result, queryClient } = await renderCalendarFeed(); result.current.enable(); - await waitFor(() => { + await vi.waitFor(() => { expect(getFeed(queryClient)).toEqual({ enabled: true, feedUrl: CALENDAR_FEED_URL, @@ -84,22 +84,22 @@ describe("useCalendarFeed", () => { it("toasts when enable fails", async () => { enableCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); - const { result } = renderCalendarFeed(); + const { result } = await renderCalendarFeed(); result.current.enable(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to enable calendar feed"); }); expect(result.current.isEnabled).toBe(false); }); it("disables the feed optimistically", async () => { - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.disable(); - await waitFor(() => { + await vi.waitFor(() => { expect(getFeed(queryClient)?.enabled).toBe(false); }); expect(result.current.isEnabled).toBe(false); @@ -109,11 +109,11 @@ describe("useCalendarFeed", () => { it("rolls back and toasts when disable fails", async () => { disableCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.disable(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to disable calendar feed"); }); expect(getFeed(queryClient)).toEqual(enabledFeed); @@ -121,11 +121,11 @@ describe("useCalendarFeed", () => { }); it("rotates the token and invalidates to the new URL", async () => { - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.rotate.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(getFeed(queryClient)).toEqual({ enabled: true, feedUrl: CALENDAR_FEED_ROTATED_URL, @@ -138,22 +138,22 @@ describe("useCalendarFeed", () => { it("toasts when rotate fails", async () => { rotateCalendarFeedTokenMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.rotate.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to regenerate URL"); }); expect(getFeed(queryClient)).toEqual(enabledFeed); }); it("deletes the feed optimistically", async () => { - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.deleteFeed.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(getFeed(queryClient)).toEqual({ enabled: false }); }); expect(result.current.isEnabled).toBe(false); @@ -163,11 +163,11 @@ describe("useCalendarFeed", () => { it("rolls back and toasts when delete fails", async () => { deleteCalendarFeedMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderCalendarFeed(enabledFeed); + const { result, queryClient } = await renderCalendarFeed(enabledFeed); result.current.deleteFeed.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to disable calendar feed"); }); expect(getFeed(queryClient)).toEqual(enabledFeed); diff --git a/apps/web/hooks/use-dashboard-mutations.test.tsx b/apps/web/hooks/use-dashboard-mutations.test.tsx index de7fd006..0a655c8b 100644 --- a/apps/web/hooks/use-dashboard-mutations.test.tsx +++ b/apps/web/hooks/use-dashboard-mutations.test.tsx @@ -20,7 +20,7 @@ import { makeDashboardDomains, makeTrackedDomain, } from "@/components/dashboard/test-fixtures"; -import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { createTestQueryClient, renderHook } from "@/mocks/react"; import { bulkArchiveDomainsMutation, bulkRemoveDomainsMutation, @@ -69,7 +69,7 @@ function getSubscription(queryClient: ReturnType) return queryClient.getQueryData(SUBSCRIPTION_QUERY_KEY); } -function renderDashboardMutations(options?: { +async function renderDashboardMutations(options?: { domains?: TrackedDomainWithDetails[]; subscription?: SubscriptionCache; }) { @@ -80,7 +80,7 @@ function renderDashboardMutations(options?: { queryClient.setQueryData(DOMAINS_QUERY_KEY, domains); queryClient.setQueryData(SUBSCRIPTION_QUERY_KEY, subscription); - const view = renderHook(() => useDashboardMutations(), { + const view = await renderHook(() => useDashboardMutations(), { wrapper: ({ children }) => ( {children} ), @@ -102,13 +102,13 @@ describe("useDashboardMutations", () => { }); it("removes a domain and decrements active count", async () => { - const { result, queryClient } = renderDashboardMutations({ + const { result, queryClient } = await renderDashboardMutations({ subscription: defaultSubscription({ planQuota: 4, canAddMore: false }), }); result.current.remove("domain-alpha"); - await waitFor(() => { + await vi.waitFor(() => { expect(getDomains(queryClient).map((d) => d.id)).not.toContain("domain-alpha"); }); expect(getSubscription(queryClient)).toMatchObject({ @@ -121,11 +121,11 @@ describe("useDashboardMutations", () => { }); it("archives a domain and moves it from active to archived counts", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); result.current.archive("domain-alpha"); - await waitFor(() => { + await vi.waitFor(() => { expect( getDomains(queryClient).find((d) => d.id === "domain-alpha")?.archivedAt, ).toBeInstanceOf(Date); @@ -139,11 +139,11 @@ describe("useDashboardMutations", () => { }); it("unarchives a domain and reverses the counts", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); result.current.unarchive("domain-archived"); - await waitFor(() => { + await vi.waitFor(() => { expect( getDomains(queryClient).find((d) => d.id === "domain-archived")?.archivedAt, ).toBeNull(); @@ -157,25 +157,25 @@ describe("useDashboardMutations", () => { }); it("mutes a domain without touching subscription cache", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); const subscriptionBefore = getSubscription(queryClient); result.current.setMuted("domain-alpha", true); - await waitFor(() => { + await vi.waitFor(() => { expect(getDomains(queryClient).find((d) => d.id === "domain-alpha")?.muted).toBe(true); }); expect(getSubscription(queryClient)).toEqual(subscriptionBefore); expect(toast.success).toHaveBeenCalledWith("Domain muted"); result.current.setMuted("domain-alpha", false); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.success).toHaveBeenCalledWith("Domain unmuted"); }); }); it("bulk-archives only non-archived ids when counting subscription changes", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); await result.current.bulkArchive(["domain-alpha", "domain-archived"]); @@ -194,7 +194,7 @@ describe("useDashboardMutations", () => { it("toasts requested count when some ids were already archived", async () => { bulkArchiveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 0 }); - const { result } = renderDashboardMutations(); + const { result } = await renderDashboardMutations(); await result.current.bulkArchive(["domain-alpha", "domain-archived"]); @@ -203,7 +203,7 @@ describe("useDashboardMutations", () => { it("toasts a warning when bulk archive only partially succeeds", async () => { bulkArchiveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 1 }); - const { result } = renderDashboardMutations(); + const { result } = await renderDashboardMutations(); await result.current.bulkArchive(["domain-alpha", "domain-beta"]); @@ -212,7 +212,7 @@ describe("useDashboardMutations", () => { }); it("toasts a singular success when one domain is archived", async () => { - const { result } = renderDashboardMutations(); + const { result } = await renderDashboardMutations(); await result.current.bulkArchive(["domain-alpha"]); @@ -220,7 +220,7 @@ describe("useDashboardMutations", () => { }); it("bulk-deletes ids and decrements active and archived counts by lifecycle state", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); await result.current.bulkDelete(["domain-alpha", "domain-archived"]); @@ -238,7 +238,7 @@ describe("useDashboardMutations", () => { }); it("bulk-mutes ids across listDomains cache variants without touching subscription", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); const archivedListKey = [...DOMAINS_QUERY_KEY, { includeArchived: true }] as const; queryClient.setQueryData(archivedListKey, getDomains(queryClient)); const subscriptionBefore = getSubscription(queryClient); @@ -260,7 +260,7 @@ describe("useDashboardMutations", () => { }); it("toasts unmute success and a warning when bulk mute only partially succeeds", async () => { - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); await result.current.bulkSetMuted(["domain-alpha"], false); expect(getDomains(queryClient).find((d) => d.id === "domain-alpha")?.muted).toBe(false); @@ -273,12 +273,12 @@ describe("useDashboardMutations", () => { it("rolls back muted flags when bulk mute fails", async () => { bulkSetMutedMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); const domainsBefore = getDomains(queryClient); await expect(result.current.bulkSetMuted(["domain-alpha"], true)).rejects.toThrow("nope"); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to mute domains"); }); expect(getDomains(queryClient)).toEqual(domainsBefore); @@ -286,7 +286,7 @@ describe("useDashboardMutations", () => { it("toasts a warning when bulk delete only partially succeeds", async () => { bulkRemoveDomainsMutation.mockResolvedValueOnce({ successCount: 1, failedCount: 1 }); - const { result } = renderDashboardMutations(); + const { result } = await renderDashboardMutations(); await result.current.bulkDelete(["domain-alpha", "domain-beta"]); @@ -296,13 +296,13 @@ describe("useDashboardMutations", () => { it("rolls back domains and subscription when remove fails", async () => { removeDomainMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); const domainsBefore = getDomains(queryClient); const subscriptionBefore = getSubscription(queryClient); result.current.remove("domain-alpha"); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to remove domain"); }); expect(getDomains(queryClient)).toEqual(domainsBefore); @@ -311,13 +311,13 @@ describe("useDashboardMutations", () => { it("rolls back and toasts when bulk archive fails", async () => { bulkArchiveDomainsMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderDashboardMutations(); + const { result, queryClient } = await renderDashboardMutations(); const domainsBefore = getDomains(queryClient); const subscriptionBefore = getSubscription(queryClient); await expect(result.current.bulkArchive(["domain-alpha"])).rejects.toThrow("nope"); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to archive domains"); }); expect(getDomains(queryClient)).toEqual(domainsBefore); diff --git a/apps/web/hooks/use-hydrated-now.test.tsx b/apps/web/hooks/use-hydrated-now.test.tsx index 28392713..d21fd102 100644 --- a/apps/web/hooks/use-hydrated-now.test.tsx +++ b/apps/web/hooks/use-hydrated-now.test.tsx @@ -5,11 +5,13 @@ import { renderHook } from "@/mocks/react"; describe("useHydratedNow", () => { it("stays null on the first render after resetHydratedNow(null)", async () => { vi.resetModules(); + const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); const { resetHydratedNow, useHydratedNow } = await import("./use-hydrated-now"); resetHydratedNow(null); - const { result } = renderHook(() => useHydratedNow()); + const { result } = await renderHook(() => useHydratedNow()); expect(result.current).toBeNull(); + raf.mockRestore(); }); }); diff --git a/apps/web/hooks/use-notifications-data.test.tsx b/apps/web/hooks/use-notifications-data.test.tsx index 5ffe70c0..47b2fed1 100644 --- a/apps/web/hooks/use-notifications-data.test.tsx +++ b/apps/web/hooks/use-notifications-data.test.tsx @@ -18,7 +18,7 @@ import { makeNotification, makeNotificationsInfiniteData, } from "@/components/notifications/test-fixtures"; -import { createTestQueryClient, renderHook, waitFor } from "@/mocks/react"; +import { createTestQueryClient, renderHook } from "@/mocks/react"; import { listNotificationsQuery, markAllReadMutation, @@ -57,7 +57,7 @@ function pageItems( return data?.pages.flatMap((page) => page.items) ?? []; } -function renderNotificationsData(options?: { +async function renderNotificationsData(options?: { items?: NotificationData[]; filter?: "unread" | "read"; enabled?: boolean; @@ -82,7 +82,7 @@ function renderNotificationsData(options?: { ); } - const view = renderHook( + const view = await renderHook( () => useNotificationsData({ filter: options?.filter ?? "unread", @@ -110,21 +110,21 @@ describe("useNotificationsData", () => { }); it("does not fetch the list when the popover is closed", async () => { - const { result } = renderNotificationsData({ enabled: false, items: [unreadAlpha] }); + const { result } = await renderNotificationsData({ enabled: false, items: [unreadAlpha] }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.count).toBe(1); }); expect(listNotificationsQuery).not.toHaveBeenCalled(); }); it("fetches the read list via listNotificationsQuery", async () => { - const { result } = renderNotificationsData({ + const { result } = await renderNotificationsData({ filter: "read", seedReadList: false, }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.notifications.map((item) => item.id)).toContain("notif-gamma"); }); expect(listNotificationsQuery).toHaveBeenCalledWith( @@ -133,11 +133,11 @@ describe("useNotificationsData", () => { }); it("markRead moves the item from inbox to archive and decrements the count", async () => { - const { result, queryClient } = renderNotificationsData(); + const { result, queryClient } = await renderNotificationsData(); result.current.markRead.mutate({ id: "notif-alpha" }); - await waitFor(() => { + await vi.waitFor(() => { expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual(["notif-beta"]); }); const archived = pageItems(queryClient, "read"); @@ -149,11 +149,11 @@ describe("useNotificationsData", () => { it("rolls back and toasts when markRead fails", async () => { markReadMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderNotificationsData(); + const { result, queryClient } = await renderNotificationsData(); result.current.markRead.mutate({ id: "notif-alpha" }); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to mark notification as read"); }); expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual([ @@ -165,11 +165,11 @@ describe("useNotificationsData", () => { }); it("markAllRead clears inbox and prepends those items onto archive", async () => { - const { result, queryClient } = renderNotificationsData(); + const { result, queryClient } = await renderNotificationsData(); result.current.markAllRead.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(pageItems(queryClient, "unread")).toEqual([]); }); expect(pageItems(queryClient, "read").map((item) => item.id)).toEqual([ @@ -183,11 +183,11 @@ describe("useNotificationsData", () => { it("rolls back and toasts when markAllRead fails", async () => { markAllReadMutation.mockRejectedValueOnce(new Error("nope")); - const { result, queryClient } = renderNotificationsData(); + const { result, queryClient } = await renderNotificationsData(); result.current.markAllRead.mutate(); - await waitFor(() => { + await vi.waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to mark notifications as read"); }); expect(pageItems(queryClient, "unread").map((item) => item.id)).toEqual([ diff --git a/apps/web/mocks/react.tsx b/apps/web/mocks/react.tsx index 219106a5..ba3ad8cd 100644 --- a/apps/web/mocks/react.tsx +++ b/apps/web/mocks/react.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { type RenderOptions, render } from "@testing-library/react"; import { Provider as JotaiProvider } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; +import { LazyMotion, MotionConfig, domMax } from "motion/react"; +import { type ComponentRenderOptions, render as baseRender } from "vitest-browser-react"; /** * Creates a QueryClient configured for testing. @@ -46,22 +47,27 @@ function HydrateAtoms({ } /** - * Test wrapper that provides QueryClientProvider and JotaiProvider with fresh - * instances for each test. Ensures test isolation. + * Test wrapper that provides QueryClient, Jotai, and Motion with a fresh + * instance for each test. `reducedMotion="always"` skips enter/exit so + * `m.*` components don't stay stuck at `opacity: 0`. */ function createWrapper(queryClient: QueryClient, initialAtomValues: AtomTuple[] = []) { return function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + + ); }; } -interface CustomRenderOptions extends Omit { +interface CustomRenderOptions extends Omit { /** * Optional QueryClient instance. If not provided, a new one will be created * using createTestQueryClient(). @@ -79,41 +85,33 @@ interface CustomRenderOptions extends Omit { * * Usage: * ```tsx - * import { render, screen } from '@/lib/test-utils' + * import { page } from "vitest/browser" + * import { render } from "@/mocks/react" * - * it('renders component with React Query', () => { - * render() - * expect(screen.getByText('Hello')).toBeInTheDocument() - * }) - * - * // With custom QueryClient - * it('uses prefilled cache', () => { - * const queryClient = createTestQueryClient() - * queryClient.setQueryData(['key'], { data: 'value' }) - * render(, { queryClient }) + * it("renders component with React Query", async () => { + * await render() + * await expect.element(page.getByText("Hello")).toBeInTheDocument() * }) * ``` * * @see https://tanstack.com/query/latest/docs/framework/react/guides/testing */ -function customRender(ui: React.ReactElement, options?: CustomRenderOptions) { +export async function render(ui: React.ReactNode, options?: CustomRenderOptions) { const { queryClient = createTestQueryClient(), initialAtomValues = [], ...renderOptions } = options ?? {}; + const screen = await baseRender(ui, { + wrapper: createWrapper(queryClient, initialAtomValues), + ...renderOptions, + }); + return { - ...render(ui, { - wrapper: createWrapper(queryClient, initialAtomValues), - ...renderOptions, - }), + ...screen, queryClient, }; } -// Re-export everything from @testing-library/react -export * from "@testing-library/react"; - -// Override render with our custom version -export { customRender as render }; +export { renderHook } from "vitest-browser-react"; diff --git a/apps/web/package.json b/apps/web/package.json index facab45f..095a1e53 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -83,24 +83,21 @@ "@domainstack/typescript-config": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", - "@testing-library/dom": "10.4.1", - "@testing-library/jest-dom": "7.0.1", - "@testing-library/react": "16.3.3", - "@testing-library/user-event": "14.6.7", "@types/node": "catalog:", "@types/react": "19.2.18", "@types/react-dom": "19.2.7", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.1.1", - "@vitest/browser": "^4.1.11", - "@vitest/browser-playwright": "^4.1.11", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/browser": "^5.0.0", + "@vitest/browser-playwright": "^5.0.0", + "@vitest/coverage-v8": "^5.0.0", "babel-plugin-react-compiler": "1.0.0", "bufferutil": "^4.1.0", "msw": "^2.15.0", "playwright": "^1.63.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "vitest": "catalog:" + "vitest": "catalog:", + "vitest-browser-react": "^2.3.0" } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index c7bcb4b1..61e36849 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,5 +1,7 @@ const config = { - plugins: ["@tailwindcss/postcss"], + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8bb16608..c38222d6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@domainstack/typescript-config/nextjs.json", "compilerOptions": { - "types": ["@testing-library/jest-dom", "vitest/globals"], + "types": ["vitest/globals", "vitest/browser"], "paths": { "@/*": ["./*"] } diff --git a/apps/web/vitest.browser.css b/apps/web/vitest.browser.css new file mode 100644 index 00000000..1dc96760 --- /dev/null +++ b/apps/web/vitest.browser.css @@ -0,0 +1,4 @@ +/* Skip rendering virtualization so hover/a11y queries see real layout. */ +[class*="[content-visibility:auto]"] { + content-visibility: visible !important; +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 487affdd..fc9b8c4e 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -12,10 +12,10 @@ export default defineConfig({ "next/link": fileURLToPath(new URL("./mocks/next-link.ts", import.meta.url)), }, tsconfigPaths: true, - dedupe: ["react", "react-dom", "nuqs"], + dedupe: ["react", "react-dom", "nuqs", "@base-ui/react"], }, optimizeDeps: { - include: ["nuqs", "nuqs/adapters/testing"], + include: ["nuqs", "nuqs/adapters/testing", "react", "react-dom", "react/jsx-runtime"], }, define: { "process.env.NEXT_PUBLIC_BASE_URL": JSON.stringify("https://test.domainstack.io"), @@ -49,7 +49,11 @@ export default defineConfig({ include: ["**/*.test.tsx"], browser: { enabled: true, - provider: playwright(), + provider: playwright({ + contextOptions: { + reducedMotion: "reduce", + }, + }), headless: true, instances: [ { diff --git a/apps/web/vitest.setup.browser.ts b/apps/web/vitest.setup.browser.ts index 3e6b0888..0420fdda 100644 --- a/apps/web/vitest.setup.browser.ts +++ b/apps/web/vitest.setup.browser.ts @@ -5,8 +5,11 @@ globalThis.process = { cwd: () => "/", }; -import "@testing-library/jest-dom/vitest"; import { vi } from "vitest"; +import "vitest-browser-react"; + +import "./app/globals.css"; +import "./vitest.browser.css"; // Mock fetch to prevent network requests globalThis.fetch = vi.fn(() => { diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 2d697188..64dd09bd 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -24,6 +24,7 @@ function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdr "fixed inset-0 isolate z-50 bg-black/10 backdrop-blur-xs dark:bg-black/50", "data-open:animate-in data-open:duration-200 data-open:fade-in-0", "data-closed:animate-out data-closed:duration-200 data-closed:fade-out-0", + "motion-reduce:animate-none motion-reduce:transition-none", // iOS 26+: ensure backdrops cover the visual viewport "supports-[-webkit-touch-callout:none]:absolute", className, @@ -44,6 +45,7 @@ function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup. "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-5 text-foreground shadow-lg outline-hidden sm:max-w-lg", "data-open:animate-in data-open:duration-200 data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:duration-200 data-closed:fade-out-0 data-closed:zoom-out-95", + "motion-reduce:animate-none motion-reduce:transition-none", // Nested dialog styling: Dim the parent popup "data-[nested-dialog-open]:after:absolute data-[nested-dialog-open]:after:inset-0 data-[nested-dialog-open]:after:z-50 data-[nested-dialog-open]:after:rounded-[inherit] data-[nested-dialog-open]:after:bg-black/10 data-[nested-dialog-open]:after:content-['']", // Prevent interaction with parent dialog when nested dialog is open diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx index 2db9c015..8d87a698 100644 --- a/packages/ui/src/components/alert.tsx +++ b/packages/ui/src/components/alert.tsx @@ -4,7 +4,7 @@ import { useRender } from "@base-ui/react/use-render"; import { cn, cva, type VariantProps } from "../utils"; const alertVariants = cva({ - base: "relative grid w-full animate-in grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border bg-card/40 px-4 py-3 text-sm backdrop-blur-lg duration-200 fade-in-0 slide-in-from-top-2 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-2 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + base: "relative grid w-full animate-in grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border bg-card/40 px-4 py-3 text-sm backdrop-blur-lg duration-200 fade-in-0 slide-in-from-top-2 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-2 motion-reduce:animate-none motion-reduce:transition-none [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", variants: { variant: { default: "text-card-foreground", diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index caacb7ef..0f57f390 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -105,7 +105,7 @@ function ComboboxContent({ "origin-[var(--transform-origin)] overflow-hidden rounded-lg duration-100", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", - "motion-reduce:animate-none", + "motion-reduce:animate-none motion-reduce:transition-none", "data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "*:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none", className, diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx index a77e86ae..21b9bfac 100644 --- a/packages/ui/src/components/context-menu.tsx +++ b/packages/ui/src/components/context-menu.tsx @@ -99,6 +99,7 @@ function ContextMenuContent({ "max-h-[var(--available-height)] origin-[var(--transform-origin)] overflow-x-hidden overflow-y-auto duration-100", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + "motion-reduce:animate-none motion-reduce:transition-none", "data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index fea14498..55d4aa8f 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -40,6 +40,7 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) "fixed inset-0 isolate z-50 bg-black/10 backdrop-blur-xs dark:bg-black/50", "data-open:animate-in data-open:duration-200 data-open:fade-in-0", "data-closed:animate-out data-closed:duration-200 data-closed:fade-out-0", + "motion-reduce:animate-none motion-reduce:transition-none", // iOS 26+: ensure backdrops cover the visual viewport "supports-[-webkit-touch-callout:none]:absolute", className, @@ -69,6 +70,7 @@ function DialogContent({ "gap-4 rounded-lg border bg-background p-5 text-foreground shadow-lg outline-hidden", "data-open:animate-in data-open:duration-200 data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:duration-200 data-closed:fade-out-0 data-closed:zoom-out-95", + "motion-reduce:animate-none motion-reduce:transition-none", // Nested dialog styling: Dim the parent popup "data-[nested-dialog-open]:after:absolute data-[nested-dialog-open]:after:inset-0 data-[nested-dialog-open]:after:z-50 data-[nested-dialog-open]:after:rounded-[inherit] data-[nested-dialog-open]:after:bg-black/10 data-[nested-dialog-open]:after:content-['']", // Prevent interaction with parent dialog when nested dialog is open diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx index c1c7ab4c..185752b9 100644 --- a/packages/ui/src/components/drawer.tsx +++ b/packages/ui/src/components/drawer.tsx @@ -25,7 +25,7 @@ function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props) ({ "origin-[var(--transform-origin)]", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + "motion-reduce:animate-none motion-reduce:transition-none", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", popoverWidth, )} diff --git a/packages/ui/src/components/navigation-menu.tsx b/packages/ui/src/components/navigation-menu.tsx index 9adbd415..2d908ab0 100644 --- a/packages/ui/src/components/navigation-menu.tsx +++ b/packages/ui/src/components/navigation-menu.tsx @@ -79,6 +79,7 @@ function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive. "top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto", "data-open:animate-in data-open:fade-in-0", "data-closed:animate-out data-closed:fade-out-0", + "motion-reduce:animate-none motion-reduce:transition-none", className, )} {...props} @@ -94,7 +95,7 @@ function NavigationMenuViewport({ className, ...props }: NavigationMenuPrimitive sideOffset={10} collisionPadding={{ top: 5, bottom: 5, left: 20, right: 20 }} className={cn( - "z-50 w-[var(--positioner-width)] max-w-[var(--available-width)] transition-[top,left,right,bottom] duration-200 ease-out will-change-[top,left,right,bottom]", + "z-50 w-[var(--positioner-width)] max-w-[var(--available-width)] transition-[top,left,right,bottom] duration-200 ease-out will-change-[top,left,right,bottom] motion-reduce:transition-none", )} > =22'} @@ -1072,6 +1060,9 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} @@ -3152,41 +3143,6 @@ packages: resolution: {integrity: sha512-GwdDyGGr6UXAtubF14yAwcXvdaogqfsQgIk89Suebjxob/Rjq2xvfmXsjDF1rQmKmhHsJm9TOY7myxv3i6geJw==} engines: {node: '>=20'} - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@7.0.1': - resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} - engines: {node: '>=22', npm: '>=6', yarn: '>=1'} - peerDependencies: - '@testing-library/dom': '>=10 <11' - vitest: '>= 0.32' - peerDependenciesMeta: - vitest: - optional: true - - '@testing-library/react@16.3.3': - resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7 - react: 19.2.8 - react-dom: 19.2.8 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@testing-library/user-event@14.6.7': - resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -3247,9 +3203,6 @@ packages: cpu: [arm64] os: [win32] - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3536,11 +3489,22 @@ packages: playwright: '*' vitest: 4.1.11 + '@vitest/browser-playwright@5.0.0': + resolution: {integrity: sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==} + peerDependencies: + playwright: '*' + vitest: 5.0.0 + '@vitest/browser@4.1.11': resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: vitest: 4.1.11 + '@vitest/browser@5.0.0': + resolution: {integrity: sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==} + peerDependencies: + vitest: 5.0.0 + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -3550,9 +3514,26 @@ packages: '@vitest/browser': optional: true + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} + '@vitest/mocker@4.1.11': resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: @@ -3564,9 +3545,23 @@ packages: vite: optional: true + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@5.0.0': + resolution: {integrity: sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==} + '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} @@ -3576,9 +3571,20 @@ packages: '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + + '@vitest/ui@5.0.0': + resolution: {integrity: sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==} + peerDependencies: + vitest: 5.0.0 + '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@5.0.0': + resolution: {integrity: sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3837,10 +3843,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3853,13 +3855,6 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4354,9 +4349,6 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -4467,12 +4459,6 @@ packages: devtools-protocol@0.0.1624250: resolution: {integrity: sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==} - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4825,6 +4811,9 @@ packages: fflate@0.4.9: resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4864,6 +4853,9 @@ packages: resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} engines: {node: '>=18'} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + form-data-encoder@4.1.0: resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} engines: {node: '>= 18'} @@ -5530,13 +5522,12 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -5766,10 +5757,6 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -6363,10 +6350,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -6474,9 +6457,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -6530,10 +6510,6 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -6912,10 +6888,6 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -7042,6 +7014,14 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -7332,6 +7312,20 @@ packages: yaml: optional: true + vitest-browser-react@2.3.0: + resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==} + peerDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7 + react: 19.2.8 + react-dom: 19.2.8 + vitest: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7373,6 +7367,47 @@ packages: jsdom: optional: true + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -7574,8 +7609,6 @@ packages: snapshots: - '@adobe/css-tools@4.5.0': {} - '@ai-sdk/gateway@4.0.75(zod@4.5.4)': dependencies: '@ai-sdk/provider': 4.0.10 @@ -7832,7 +7865,10 @@ snapshots: '@better-fetch/fetch@1.3.1': {} - '@blazediff/core@1.9.1': {} + '@blazediff/core@1.10.0': {} + + '@blazediff/core@1.9.1': + optional: true '@borewit/text-codec@0.2.2': {} @@ -8808,11 +8844,11 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@polar-sh/better-auth@1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stripe/stripe-js@7.9.0)(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(react@19.2.8)(zod@4.5.4)': + '@polar-sh/better-auth@1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stripe/stripe-js@7.9.0)(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0))(react@19.2.8)(zod@4.5.4)': dependencies: '@polar-sh/checkout': 0.2.1(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stripe/stripe-js@7.9.0)(react@19.2.8) '@polar-sh/sdk': 0.47.1 - better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11) + better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) zod: 4.5.4 transitivePeerDependencies: - '@stripe/react-stripe-js' @@ -9362,43 +9398,6 @@ snapshots: dependencies: '@tanstack/store': 0.11.1 - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)': - dependencies: - '@adobe/css-tools': 4.5.0 - '@testing-library/dom': 10.4.1 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - optionalDependencies: - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) - - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) - - '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': - dependencies: - '@testing-library/dom': 10.4.1 - '@tokenizer/inflate@0.4.1(supports-color@10.2.2)': dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -9450,8 +9449,6 @@ snapshots: '@turbo/windows-arm64@2.10.12': optional: true - '@types/aria-query@5.0.4': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -9680,14 +9677,28 @@ snapshots: - msw - utf-8-validate - vite + optional: true - '@vitest/browser-playwright@4.1.11(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@4.1.11)': + '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0)': dependencies: - '@vitest/browser': 4.1.11(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@4.1.11) - '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) playwright: 1.63.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser-playwright@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + playwright: 1.63.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) transitivePeerDependencies: - bufferutil - msw @@ -9711,17 +9722,37 @@ snapshots: - msw - utf-8-validate - vite + optional: true - '@vitest/browser@4.1.11(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@4.1.11)': + '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0)': dependencies: - '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + ws: 8.21.3(bufferutil@4.1.0) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) ws: 8.21.3(bufferutil@4.1.0) transitivePeerDependencies: - bufferutil @@ -9745,6 +9776,21 @@ snapshots: vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) optionalDependencies: '@vitest/browser': 4.1.11(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@4.1.11) + optional: true + + '@vitest/coverage-v8@5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.5 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + optionalDependencies: + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0) '@vitest/expect@4.1.11': dependencies: @@ -9754,6 +9800,13 @@ snapshots: '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))': dependencies: @@ -9763,12 +9816,24 @@ snapshots: optionalDependencies: msw: 2.15.0(@types/node@24.13.3)(typescript@7.0.2) vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13) + optional: true - '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))': + '@vitest/mocker@5.0.0(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))': dependencies: - '@vitest/spy': 4.1.11 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 estree-walker: 3.0.3 - magic-string: 0.30.21 + magic-string: 1.2.3 + optionalDependencies: + msw: 2.15.0(@types/node@24.13.3)(typescript@7.0.2) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13) + + '@vitest/mocker@5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.2.3 optionalDependencies: msw: 2.15.0(@types/node@26.4.1)(typescript@7.0.2) vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13) @@ -9776,11 +9841,17 @@ snapshots: '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 + optional: true + + '@vitest/pretty-format@5.0.0': + dependencies: + tinyrainbow: 3.1.1 '@vitest/runner@4.1.11': dependencies: '@vitest/utils': 4.1.11 pathe: 2.0.3 + optional: true '@vitest/snapshot@4.1.11': dependencies: @@ -9788,14 +9859,35 @@ snapshots: '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 + optional: true - '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.11': + optional: true + + '@vitest/spy@5.0.0': {} + + '@vitest/ui@5.0.0(vitest@5.0.0)': + dependencies: + '@vitest/utils': 5.0.0 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) '@vitest/utils@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + optional: true + + '@vitest/utils@5.0.0': + dependencies: + '@vitest/pretty-format': 5.0.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 '@webassemblyjs/ast@1.14.1': dependencies: @@ -10328,8 +10420,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} ansis@3.17.0: {} @@ -10338,12 +10428,6 @@ snapshots: dependencies: tslib: 2.8.1 - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -10447,6 +10531,37 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): + dependencies: + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0)) + '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.11 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.5.4 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0) + next: 16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + pg: 8.23.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + better-call@1.3.7(zod@4.5.4): dependencies: '@better-auth/utils': 0.4.3 @@ -10805,8 +10920,6 @@ snapshots: css-what@6.2.2: {} - css.escape@1.5.1: {} - cssesc@3.0.0: {} csstype@3.2.3: {} @@ -10898,10 +11011,6 @@ snapshots: devtools-protocol@0.0.1624250: {} - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -11283,6 +11392,8 @@ snapshots: fflate@0.4.9: {} + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -11350,6 +11461,8 @@ snapshots: semver-regex: 4.0.5 super-regex: 1.1.0 + flatted@3.4.4: {} + form-data-encoder@4.1.0: {} forwarded@0.2.0: {} @@ -11556,7 +11669,8 @@ snapshots: help-me@5.0.0: {} - html-escaper@2.0.2: {} + html-escaper@2.0.2: + optional: true html-to-text@9.0.5: dependencies: @@ -11706,18 +11820,21 @@ snapshots: isexe@4.0.0: {} - istanbul-lib-coverage@3.2.2: {} + istanbul-lib-coverage@3.2.2: + optional: true istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 make-dir: 4.0.0 supports-color: 7.2.0 + optional: true istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + optional: true iterare@1.2.1: {} @@ -11917,12 +12034,14 @@ snapshots: lru-cache@11.5.2: {} - lz-string@1.5.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: dependencies: '@babel/parser': 7.29.8 @@ -11938,6 +12057,7 @@ snapshots: make-dir@4.0.0: dependencies: semver: 7.8.5 + optional: true maplibre-gl@6.7.0: dependencies: @@ -12360,8 +12480,6 @@ snapshots: mimic-response@4.0.0: {} - min-indent@1.0.1: {} - minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -12937,12 +13055,6 @@ snapshots: prettier@3.9.6: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -13087,8 +13199,6 @@ snapshots: react-is@16.13.1: {} - react-is@17.0.2: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -13130,11 +13240,6 @@ snapshots: dependencies: resolve: 1.22.12 - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - reflect-metadata@0.2.2: {} rehype-harden@1.1.8: @@ -13604,10 +13709,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - strip-json-comments@5.0.3: {} strtok3@10.3.5: @@ -13648,6 +13749,7 @@ snapshots: supports-color@7.2.0: dependencies: has-flag: 4.0.0 + optional: true supports-color@8.1.1: dependencies: @@ -13745,7 +13847,12 @@ snapshots: dependencies: convert-hrtime: 5.0.0 - tinybench@2.9.0: {} + tinybench@2.9.0: + optional: true + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} tinyexec@1.3.1: {} @@ -14036,6 +14143,15 @@ snapshots: terser: 5.51.2 tsx: 4.23.13 + vitest-browser-react@2.3.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)): dependencies: '@vitest/expect': 4.1.11 @@ -14065,34 +14181,55 @@ snapshots: '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) transitivePeerDependencies: - msw + optional: true - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)): + vitest@5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 0.30.21 + magic-string: 1.2.3 obug: 2.1.4 - pathe: 2.0.3 picomatch: 4.0.7 std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.1 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.3 + '@vitest/browser-playwright': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) + transitivePeerDependencies: + - msw + + vitest@5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.2.3 + obug: 2.1.4 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.4.1 - '@vitest/browser-playwright': 4.1.11(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@4.1.11) - '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + '@vitest/browser-playwright': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cefdf641..55703b06 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ catalog: '@types/node': ^24.13.3 motion: ^13.2.0 sonner: ^2.0.8 - vitest: ^4.1.11 + vitest: ^5.0.0 zod: ^4.5.4 overrides: '@types/react': 19.2.18