refactor: migrate to vitest 5, remove testing-library deps

This commit is contained in:
2026-09-06 14:35:34 -04:00
parent cdec46dd10
commit 281fc801bd
65 changed files with 1733 additions and 1520 deletions
+1
View File
@@ -10,6 +10,7 @@ node_modules
coverage
__screenshots__
test-results
.vitest
.vitest-attachments
# Turbo
+2
View File
@@ -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:
@@ -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();
});
});
@@ -61,7 +61,7 @@ export function CookiePrompt({ consentRequired }: { consentRequired: boolean })
return (
<div
className={`fixed bottom-3 left-3 z-100 max-w-[260px] duration-200 ${
className={`fixed bottom-3 left-3 z-100 max-w-[260px] duration-200 motion-reduce:animate-none motion-reduce:transition-none ${
isExiting ? "animate-out slide-out-to-bottom-8" : "animate-in slide-in-from-bottom-8"
}`}
>
@@ -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<void>>(),
@@ -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(<AddDomainPageClient prefillDomain="from-report.com" />);
await render(<AddDomainPageClient prefillDomain="from-report.com" />);
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(<AddDomainPageClient />);
await render(<AddDomainPageClient />);
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(<AddDomainPageClient />);
expect(screen.getByTestId("resume")).toHaveTextContent("null");
await render(<AddDomainPageClient />);
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(<AddDomainModalClient />);
await render(<AddDomainModalClient />);
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(<AddDomainModalClient />);
await render(<AddDomainModalClient />);
await user.click(screen.getByRole("button", { name: "Close" }));
await page.getByRole("button", { name: "Close" }).click();
expect(nav.back).toHaveBeenCalledOnce();
expect(nav.push).not.toHaveBeenCalled();
@@ -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(
<AddDomainContent
onSuccess={addDomainActionSpies.onSuccess}
onClose={addDomainActionSpies.onClose}
@@ -175,8 +199,8 @@ describe("AddDomainContent", () => {
/>,
);
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();
});
});
@@ -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<typeof userEvent.setup>) {
render(
async function openShareDialog() {
await render(
<ShareInstructionsDialog
domain={DOMAIN}
verificationToken={TOKEN}
trackedDomainId={TRACKED_ID}
/>,
);
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);
});
});
@@ -34,7 +34,7 @@ export type RenderAddDomainContentOptions = {
onClose?: () => void;
};
export function renderAddDomainContent(options: RenderAddDomainContentOptions = {}) {
export async function renderAddDomainContent(options: RenderAddDomainContentOptions = {}) {
return render(
<AddDomainContent
onSuccess={options.onSuccess ?? addDomainActionSpies.onSuccess}
@@ -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("@/hooks/use-subscription", async () => {
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();
});
});
@@ -62,7 +62,7 @@ export function BulkActionsToolbar({ totalCount, className }: BulkActionsToolbar
return (
<div
className={cn(
"fixed inset-x-0 z-50 mx-auto flex w-max max-w-[calc(100%-2rem)] animate-in items-center gap-3 rounded-lg border border-black/15 bg-popover px-2.5 py-1.5 shadow-lg shadow-black/20 duration-200 fade-in-0 slide-in-from-bottom-4 motion-reduce:animate-none dark:border-white/15",
"fixed inset-x-0 z-50 mx-auto flex w-max max-w-[calc(100%-2rem)] animate-in items-center gap-3 rounded-lg border border-black/15 bg-popover px-2.5 py-1.5 shadow-lg shadow-black/20 duration-200 fade-in-0 slide-in-from-bottom-4 motion-reduce:animate-none motion-reduce:transition-none dark:border-white/15",
"bottom-[max(1rem,env(safe-area-inset-bottom))]",
className,
)}
@@ -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 } 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(<CalendarFeedPopover />, { queryClient });
await render(<CalendarFeedPopover />, { 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();
});
});
@@ -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(
<DashboardBannerDismissable
variant="success"
title="Welcome to Pro!"
@@ -19,14 +18,19 @@ describe("DashboardBannerDismissable", () => {
/>,
);
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();
});
});
@@ -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<typeof userEvent.setup>, 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"]);
});
});
@@ -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(<UpgradeBanner />);
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(<UpgradeBanner />);
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(<UpgradeBanner />);
expect(screen.queryByText("Approaching Limit")).not.toBeInTheDocument();
await render(<UpgradeBanner />);
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(<UpgradeBanner />);
await render(<UpgradeBanner />);
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(<UpgradeBanner />);
await render(<UpgradeBanner />);
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(<SubscriptionEndingBanner />);
expect(screen.queryByText("Your Pro subscription is ending")).not.toBeInTheDocument();
it("hides without an end date or when already expired", async () => {
await render(<SubscriptionEndingBanner />);
await expect
.element(page.getByText("Your Pro subscription is ending", { exact: true }))
.not.toBeInTheDocument();
mockSubscription.endsAt = daysFromTestNow(-1);
render(<SubscriptionEndingBanner />);
expect(screen.queryByText(/Pro subscription ending/)).not.toBeInTheDocument();
await render(<SubscriptionEndingBanner />);
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(<SubscriptionEndingBanner />);
await render(<SubscriptionEndingBanner />);
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(<SubscriptionEndingBanner />);
await render(<SubscriptionEndingBanner />);
expect(screen.getByText("Pro subscription ending in 2 days")).toBeInTheDocument();
await expect
.element(page.getByText("Pro subscription ending in 2 days", { exact: true }))
.toBeInTheDocument();
});
});
});
@@ -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<typeof userEvent.setup>, 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<typeof userEvent.setup>) {
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();
});
});
});
+4 -4
View File
@@ -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(
<NuqsTestingAdapter
searchParams={options.searchParams ?? ""}
hasMemory
@@ -289,11 +289,11 @@ export function renderDashboardShell(options: RenderDashboardShellOptions = {})
return { ...view, domains, urlUpdates };
}
export function renderDashboardConfirmShell(options: RenderDashboardShellOptions = {}) {
export async function renderDashboardConfirmShell(options: RenderDashboardShellOptions = {}) {
return renderDashboardShell({ ...options, confirmActions: true });
}
export function renderArchivedList(domains: TrackedDomainWithDetails[]) {
export async function renderArchivedList(domains: TrackedDomainWithDetails[]) {
mockSubscription.activeCount = 0;
return render(
<TooltipProvider>
@@ -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(<CertificatesSection data={data} />);
expect(screen.getByText("Issuer")).toBeInTheDocument();
await render(<CertificatesSection data={data} />);
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(<CertificatesSection data={null} />);
expect(screen.getByText(/No certificates found/i)).toBeInTheDocument();
it("shows empty state when no certificates", async () => {
await render(<CertificatesSection data={null} />);
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(<CertificatesSection data={data} />);
await render(<CertificatesSection data={data} />);
// 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();
});
@@ -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 }) => <div>icon:{domain}</div>,
@@ -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(<DnsRecordList records={records} type="MX" />);
await render(<DnsRecordList records={records} type="MX" />);
// 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(<DnsRecordList records={records} type="A" />);
expect(screen.getByText(/icon:cloudflare.com/i)).toBeInTheDocument();
await render(<DnsRecordList records={records} type="A" />);
await expect.element(page.getByText(/icon:cloudflare.com/i)).toBeInTheDocument();
});
});
@@ -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(<DnsSection data={{ records, resolver: null }} />);
await render(<DnsSection data={{ records, resolver: null }} />);
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(<DnsSection data={{ records: [], resolver: null }} />);
expect(screen.getByText(/No DNS records found/i)).toBeInTheDocument();
it("shows empty state when no records", async () => {
await render(<DnsSection data={{ records: [], resolver: null }} />);
await expect.element(page.getByText(/No DNS records found/i)).toBeInTheDocument();
});
});
@@ -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(<HeadersSection data={data} />);
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(<HeadersSection data={data} />);
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(<HeadersSection data={data} />);
const { container } = await render(<HeadersSection data={data} />);
// 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(<HeadersSection data={null} />);
expect(screen.getByText(/No HTTP headers detected/i)).toBeInTheDocument();
it("shows empty state when no headers", async () => {
await render(<HeadersSection data={null} />);
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(<HeadersSection data={data} />);
expect(screen.getByText("location")).toBeInTheDocument();
expect(screen.getByText("https://www.test.invalid/path")).toBeInTheDocument();
await render(<HeadersSection data={data} />);
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(<HeadersSection data={data} />);
expect(screen.getByText("location")).toBeInTheDocument();
expect(screen.getByText("/relative/path")).toBeInTheDocument();
await render(<HeadersSection data={data} />);
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(<HeadersSection data={data} />);
await render(<HeadersSection data={data} />);
// 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(<HeadersSection data={data} />);
await render(<HeadersSection data={data} />);
// 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(<HeadersSection data={data} />);
await render(<HeadersSection data={data} />);
// Check that alert is NOT displayed
expect(screen.queryByText(/HTTP 200/)).not.toBeInTheDocument();
await expect.element(page.getByText(/HTTP 200/)).not.toBeInTheDocument();
});
});
@@ -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(<HostingSection data={data} />);
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(<HostingSection data={data} />);
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(<HostingSection data={null} />);
expect(screen.getByText(/No hosting details available/i)).toBeInTheDocument();
it("shows empty state when no providers", async () => {
await render(<HostingSection data={null} />);
await expect.element(page.getByText(/No hosting details available/i)).toBeInTheDocument();
});
});
@@ -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 }) => <div data-slot="favicon" data-domain={domain} />,
@@ -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(
<RawDataDialog
domain="example.com"
format="RDAP"
@@ -27,22 +25,22 @@ describe("RawDataDialog", () => {
/>,
);
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(
<RawDataDialog
domain="example.com"
format="WHOIS"
@@ -52,12 +50,14 @@ describe("RawDataDialog", () => {
/>,
);
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();
});
});
@@ -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(
<RegistrationSection
data={
{
@@ -40,13 +41,12 @@ describe("RegistrationSection", () => {
}
/>,
);
// 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(
<RegistrationSection
data={
{
@@ -61,6 +61,6 @@ describe("RegistrationSection", () => {
}
/>,
);
expect(screen.getByText(/Registration Data Unavailable/i)).toBeInTheDocument();
await expect.element(page.getByText(/Registration Data Unavailable/i)).toBeInTheDocument();
});
});
@@ -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(<RelativeAgeString from="2020-01-01T00:00:00Z" />);
await render(<RelativeAgeString from="2020-01-01T00:00:00Z" />);
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(<RelativeAgeString from="2020-01-01T00:00:00Z" />);
await render(<RelativeAgeString from="2020-01-01T00:00:00Z" />);
expect(await screen.findByText("(5 years ago)")).toBeInTheDocument();
await expect.element(page.getByText("(5 years ago)", { exact: true })).toBeInTheDocument();
});
});
@@ -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(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
await render(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
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(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
await render(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
expect(await screen.findByText("(in 1 year)")).toBeInTheDocument();
await expect.element(page.getByText("(in 1 year)", { exact: true })).toBeInTheDocument();
});
});
@@ -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(<MetaTagsGrid metaTagValues={metaTagValues} />);
await render(<MetaTagsGrid metaTagValues={metaTagValues} />);
// 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(<MetaTagsGrid metaTagValues={metaTagValues} />);
await render(<MetaTagsGrid metaTagValues={metaTagValues} />);
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(<MetaTagsGrid metaTagValues={metaTagValues} />);
await render(<MetaTagsGrid metaTagValues={metaTagValues} />);
// 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(<MetaTagsGrid metaTagValues={metaTagValues} />);
await render(<MetaTagsGrid metaTagValues={metaTagValues} />);
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(<MetaTagsGrid metaTagValues={metaTagValues} />);
await render(<MetaTagsGrid metaTagValues={metaTagValues} />);
// 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(<MetaTagsGrid metaTagValues={testCase.metaTagValues} />);
it(`renders ${testCase.name}`, async () => {
await render(<MetaTagsGrid metaTagValues={testCase.metaTagValues} />);
// 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);
}
});
}
@@ -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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// 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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// 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(<RobotsSummary domain="test.invalid" robots={robots} />);
expect(screen.getByText(/No crawl rules detected/i)).toBeInTheDocument();
expect(screen.getByText("Sitemaps")).toBeInTheDocument();
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// "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(<RobotsSummary domain="test.invalid" robots={robots} />);
expect(screen.getByText("10")).toBeInTheDocument();
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
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(<RobotsSummary domain="test.invalid" robots={robots} />);
expect(screen.getByText("no-ai-training")).toBeInTheDocument();
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// 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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// 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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
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(<RobotsSummary domain="test.invalid" robots={robots} />);
await render(<RobotsSummary domain="test.invalid" robots={robots} />);
// 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();
});
});
});
@@ -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> = {}): 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(<SeoSection domain="test.invalid" data={data} />);
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
expect(screen.getByText(/No SEO meta detected/i)).toBeInTheDocument();
expect(screen.queryByTestId("meta-tags-grid")).not.toBeInTheDocument();
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
const tabs = screen.getByTestId("social-previews");
expect(tabs).toHaveAttribute("data-variant", "large");
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
const tabs = screen.getByTestId("social-previews");
expect(tabs).toHaveAttribute("data-variant", "compact");
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
const tabs = screen.getByTestId("social-previews");
expect(tabs).toHaveAttribute("data-variant", "large");
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
const tabs = screen.getByTestId("social-previews");
expect(tabs).toHaveAttribute("data-variant", "compact");
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
expect(screen.getByText(/We followed a redirect/i)).toBeInTheDocument();
await render(<SeoSection domain="test.invalid" data={data} />);
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(<SeoSection domain="test.invalid" data={data} />);
expect(screen.queryByText(/We followed a redirect/i)).not.toBeInTheDocument();
await render(<SeoSection domain="test.invalid" data={data} />);
await expect.element(page.getByText(/We followed a redirect/i)).not.toBeInTheDocument();
});
});
});
@@ -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(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
await render(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
// 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(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
await render(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
// 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(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
const preview = screen.getByRole("link", {
it("renders compact variant for Twitter when specified", async () => {
await render(<SocialPreviews preview={mockPreview} twitterVariant="compact" />);
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(<SocialPreviews preview={mockPreview} twitterVariant="large" />);
const preview = screen.getByRole("link", {
it("renders large variant for Twitter when specified", async () => {
await render(<SocialPreviews preview={mockPreview} twitterVariant="large" />);
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(<SocialPreviews preview={mockPreview} twitterVariant="large" />);
await render(<SocialPreviews preview={mockPreview} twitterVariant="large" />);
// 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(<SocialPreviews preview={preview} twitterVariant="compact" />);
await render(<SocialPreviews preview={preview} twitterVariant="compact" />);
// 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(<SocialPreviews preview={preview} twitterVariant="compact" />);
await render(<SocialPreviews preview={preview} twitterVariant="compact" />);
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(<SocialPreviews preview={preview} twitterVariant="compact" />);
await render(<SocialPreviews preview={preview} twitterVariant="compact" />);
// 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"));
});
});
});
@@ -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<void>>(),
@@ -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(
<TooltipProvider>
<TrackDomainButton domain={domain} />
@@ -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();
});
});
@@ -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(<NotificationsPopover />, { queryClient });
}
function setupUser() {
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
}
async function openInbox(user: ReturnType<typeof userEvent.setup>) {
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(<NotificationsPopover />, { queryClient });
await render(<NotificationsPopover />, { 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(<NotificationsPopover />, { queryClient });
await render(<NotificationsPopover />, { 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();
});
});
@@ -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 }) => (
<form
{...props}
onSubmit={(e) => {
e.preventDefault();
onFormSubmit?.();
}}
>
{children}
</form>
),
}));
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(<HeaderSearchClient />);
const input = screen.getByLabelText(/Search any domain/i);
expect(input).toHaveValue("sub.test.invalid");
await render(<HeaderSearchClient />);
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(<HeaderSearchClient />);
const input = screen.getByLabelText(/Search any domain/i);
await render(<HeaderSearchClient />);
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(<HeaderSearchClient />);
const input = screen.getByLabelText(/Search any domain/i);
const { rerender } = await render(<HeaderSearchClient />);
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(<HeaderSearchClient />);
await rerender(<HeaderSearchClient />);
finishNavigation?.();
await waitFor(() => expect(screen.getByLabelText(/Search any domain/i)).not.toBeDisabled());
await expect.element(domainSearchInput()).toBeEnabled();
});
});
@@ -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(<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} />);
await render(<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} />);
// 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(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
await render(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
// 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(<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} max={4} />);
await render(
<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} max={4} />,
);
// 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(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
await render(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
// 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(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
await render(<HomeSearchSuggestionsClient defaultSuggestions={[]} />);
// 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(<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} />);
await userEvent.click(screen.getByRole("button", { name: /example.invalid/i }));
await render(<HomeSearchSuggestionsClient defaultSuggestions={DEFAULT_TEST_SUGGESTIONS} />);
await page.getByRole("button", { name: /example.invalid/i }).click();
expect(mockSetPendingDomain).toHaveBeenCalledWith("example.invalid");
});
});
@@ -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 }) => (
<form
{...props}
onSubmit={(e) => {
e.preventDefault();
onFormSubmit?.();
}}
>
{children}
</form>
),
}));
const nav = vi.hoisted(() => ({
push: vi.fn<(href: string) => void | Promise<void>>(),
}));
@@ -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(<SearchClient variant="lg" />);
it("exposes WebMCP tool attributes for domain search", async () => {
await render(<SearchClient variant="lg" />);
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(<SearchClient variant="lg" />);
const input = screen.getByLabelText(/Search any domain/i);
await render(<SearchClient variant="lg" />);
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(<PreservedNavigationHarness />);
const input = screen.getByLabelText(/Search any domain/i);
await render(<PreservedNavigationHarness />);
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(<SearchClient variant="lg" />);
const input = screen.getByLabelText(/Search any domain/i);
await render(<SearchClient variant="lg" />);
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(<SearchClient variant="lg" />);
const { rerender } = await render(<SearchClient variant="lg" />);
// Simulate external navigation request (e.g., from suggestion click via store)
mockPendingDomain.value = "test.invalid";
rerender(<SearchClient variant="lg" />);
await rerender(<SearchClient variant="lg" />);
// 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(<SearchClient variant="sm" />);
await render(<SearchClient variant="sm" />);
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(<SearchClient variant="sm" />);
await render(<SearchClient variant="sm" />);
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(<SearchClient variant="sm" />);
await render(<SearchClient variant="sm" />);
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(<SearchClient variant="sm" />);
const { rerender } = await render(<SearchClient variant="sm" />);
// 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(<SearchClient variant="sm" />);
await rerender(<SearchClient variant="sm" />);
// 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");
});
});
+3 -3
View File
@@ -477,7 +477,7 @@ function MapMarkerPopup({
return createPortal(
<div
className={cn(
"relative animate-in rounded-md border bg-popover p-3 text-popover-foreground shadow-md fade-in-0 zoom-in-95",
"relative animate-in rounded-md border bg-popover p-3 text-popover-foreground shadow-md fade-in-0 zoom-in-95 motion-reduce:animate-none",
className,
)}
>
@@ -571,7 +571,7 @@ function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerToo
return createPortal(
<div
className={cn(
"animate-in rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md fade-in-0 zoom-in-95",
"animate-in rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md fade-in-0 zoom-in-95 motion-reduce:animate-none",
className,
)}
>
@@ -918,7 +918,7 @@ function MapPopup({
return createPortal(
<div
className={cn(
"relative animate-in rounded-md border bg-popover p-3 text-popover-foreground shadow-md fade-in-0 zoom-in-95",
"relative animate-in rounded-md border bg-popover p-3 text-popover-foreground shadow-md fade-in-0 zoom-in-95 motion-reduce:animate-none",
className,
)}
>
+19 -19
View File
@@ -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<typeof createTestQueryClient>) {
return queryClient.getQueryData<CalendarFeedData>(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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
@@ -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);
+26 -26
View File
@@ -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<typeof createTestQueryClient>)
return queryClient.getQueryData<SubscriptionCache>(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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
@@ -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);
+3 -1
View File
@@ -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();
});
});
+15 -15
View File
@@ -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([
+24 -26
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<HydrateAtoms initialValues={initialAtomValues}>{children}</HydrateAtoms>
<MotionConfig reducedMotion="always">
<LazyMotion features={domMax}>
<HydrateAtoms initialValues={initialAtomValues}>{children}</HydrateAtoms>
</LazyMotion>
</MotionConfig>
</JotaiProvider>
</QueryClientProvider>
);
};
}
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
interface CustomRenderOptions extends Omit<ComponentRenderOptions, "wrapper"> {
/**
* Optional QueryClient instance. If not provided, a new one will be created
* using createTestQueryClient().
@@ -79,41 +85,33 @@ interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
*
* 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(<MyComponent />)
* expect(screen.getByText('Hello')).toBeInTheDocument()
* })
*
* // With custom QueryClient
* it('uses prefilled cache', () => {
* const queryClient = createTestQueryClient()
* queryClient.setQueryData(['key'], { data: 'value' })
* render(<MyComponent />, { queryClient })
* it("renders component with React Query", async () => {
* await render(<MyComponent />)
* 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";
+5 -8
View File
@@ -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"
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
const config = {
plugins: ["@tailwindcss/postcss"],
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1 -1
View File
@@ -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": {
"@/*": ["./*"]
}
+4
View File
@@ -0,0 +1,4 @@
/* Skip rendering virtualization so hover/a11y queries see real layout. */
[class*="[content-visibility:auto]"] {
content-visibility: visible !important;
}
+7 -3
View File
@@ -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: [
{
+4 -1
View File
@@ -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<typeof fetch>(() => {
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
@@ -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,
)}
+2
View File
@@ -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
+2 -2
View File
@@ -25,7 +25,7 @@ function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props)
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 z-50 bg-black/10 backdrop-blur-xs motion-reduce:animate-none motion-reduce:transition-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
// iOS 26+: ensure backdrops cover the visual viewport
"supports-[-webkit-touch-callout:none]:absolute",
className,
@@ -45,7 +45,7 @@ function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background text-sm shadow-lg outline-hidden",
"data-[nested-dialog-open]:pointer-events-none 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-['']",
"data-open:animate-in data-open:fade-in-0 data-[swipe-direction=down]:data-open:slide-in-from-bottom-10 data-[swipe-direction=left]:data-open:slide-in-from-left-10 data-[swipe-direction=right]:data-open:slide-in-from-right-10 data-[swipe-direction=up]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[swipe-direction=down]:data-closed:slide-out-to-bottom-10 data-[swipe-direction=left]:data-closed:slide-out-to-left-10 data-[swipe-direction=right]:data-closed:slide-out-to-right-10 data-[swipe-direction=up]:data-closed:slide-out-to-top-10",
"motion-reduce:animate-none motion-reduce:transition-none data-open:animate-in data-open:fade-in-0 data-[swipe-direction=down]:data-open:slide-in-from-bottom-10 data-[swipe-direction=left]:data-open:slide-in-from-left-10 data-[swipe-direction=right]:data-open:slide-in-from-right-10 data-[swipe-direction=up]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[swipe-direction=down]:data-closed:slide-out-to-bottom-10 data-[swipe-direction=left]:data-closed:slide-out-to-left-10 data-[swipe-direction=right]:data-closed:slide-out-to-right-10 data-[swipe-direction=up]:data-closed:slide-out-to-top-10",
"data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0",
"data-[swipe-direction=down]:mt-24 data-[swipe-direction=down]:max-h-[80vh] data-[swipe-direction=left]:h-full data-[swipe-direction=left]:w-3/4 data-[swipe-direction=right]:h-full data-[swipe-direction=right]:w-3/4 data-[swipe-direction=up]:mb-24 data-[swipe-direction=up]:max-h-[80vh]",
"data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b data-[swipe-direction=left]:sm:max-w-sm data-[swipe-direction=right]:sm:max-w-sm",
@@ -42,6 +42,7 @@ function DropdownMenuContent({
"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:overflow-hidden 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,
)}
@@ -52,6 +52,7 @@ function HoverCardContent({
"origin-[var(--transform-origin)] 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,
)}
+2
View File
@@ -73,6 +73,7 @@ function MenubarContent({
"max-h-[var(--available-height)] origin-[var(--transform-origin)] 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,
)}
@@ -248,6 +249,7 @@ function MenubarSubContent({
"max-h-[var(--available-height)] origin-[var(--transform-origin)] 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,
)}
+2
View File
@@ -37,6 +37,7 @@ function ModalOverlay({ 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,
@@ -63,6 +64,7 @@ function ModalContent({
"rounded-lg border bg-background 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",
className,
)}
{...props}
@@ -211,6 +211,7 @@ export function MultiSelect<T extends string>({
"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,
)}
@@ -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",
)}
>
<NavigationMenuPrimitive.Popup
@@ -104,6 +105,7 @@ function NavigationMenuViewport({ className, ...props }: NavigationMenuPrimitive
"origin-[var(--transform-origin)]",
"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",
)}
>
<NavigationMenuPrimitive.Viewport
+1
View File
@@ -58,6 +58,7 @@ function PopoverContent({
"origin-[var(--transform-origin)] 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,
)}
@@ -107,6 +107,7 @@ function ResponsiveTooltipContent({
"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=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,
)}
+1
View File
@@ -86,6 +86,7 @@ function SelectContent({
"max-h-[var(--available-height)] w-[var(--anchor-width)] 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=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-[align-trigger=true]:animate-none",
className,
+2 -2
View File
@@ -27,7 +27,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs motion-reduce:animate-none motion-reduce:transition-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
// iOS 26+: ensure backdrops cover the visual viewport
"supports-[-webkit-touch-callout:none]:absolute",
className,
@@ -54,7 +54,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-reduce:animate-none motion-reduce:transition-none data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
"data-[nested-dialog-open]:pointer-events-none 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:bg-black/10 data-[nested-dialog-open]:after:content-['']",
className,
)}
+1
View File
@@ -42,6 +42,7 @@ function TooltipContent({
"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=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,
)}
+359 -222
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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