mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:05:34 -04:00
refactor: migrate to vitest 5, remove testing-library deps
This commit is contained in:
@@ -10,6 +10,7 @@ node_modules
|
||||
coverage
|
||||
__screenshots__
|
||||
test-results
|
||||
.vitest
|
||||
.vitest-attachments
|
||||
|
||||
# Turbo
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Skip rendering virtualization so hover/a11y queries see real layout. */
|
||||
[class*="[content-visibility:auto]"] {
|
||||
content-visibility: visible !important;
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
Generated
+359
-222
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user