Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BlockKitMediaPickerField } from "../../src/components/BlockKitMediaPickerField";
import { render } from "../utils/render";
// Stub MediaPickerModal as a test seam so tests can drive the picker selection
// flow (local pick, URL pick, close) without spinning up the real modal.
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: ({
open,
onSelect,
onOpenChange,
}: {
open: boolean;
onSelect: (item: unknown) => void;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="media-picker-modal">
<button
type="button"
onClick={() =>
onSelect({
id: "m1",
filename: "photo.png",
mimeType: "image/png",
url: "/media/photo.png",
storageKey: "photo.png",
provider: "local",
size: 0,
createdAt: "",
})
}
>
pick-local
</button>
<button
type="button"
onClick={() =>
onSelect({
id: "",
filename: "ext.jpg",
mimeType: "image/unknown",
url: "https://cdn.example/ext.jpg",
size: 0,
createdAt: "",
})
}
>
pick-url
</button>
<button type="button" onClick={() => onOpenChange(false)}>
close
</button>
</div>
) : null,
}));
async function renderField(
props: Partial<React.ComponentProps<typeof BlockKitMediaPickerField>> = {},
) {
const onChange = props.onChange ?? vi.fn();
const screen = await render(
<BlockKitMediaPickerField
actionId="hero"
label="Hero"
value=""
onChange={onChange}
{...props}
/>,
);
return { screen, onChange };
}
async function waitForImg(): Promise<HTMLImageElement> {
let el: HTMLImageElement | null = null;
await vi.waitFor(
() => {
el = document.querySelector("img");
expect(el).toBeTruthy();
},
{ timeout: 2000 },
);
return el!;
}
describe("BlockKitMediaPickerField", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("empty state", () => {
it("renders the default placeholder when no value is set", async () => {
const { screen } = await renderField();
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
});
it("renders the custom placeholder when provided", async () => {
const { screen } = await renderField({ placeholder: "Pick a hero image" });
await expect.element(screen.getByText("Pick a hero image")).toBeInTheDocument();
});
it("opens the picker when the empty-state button is clicked", async () => {
const { screen } = await renderField({ placeholder: "Pick a hero image" });
const trigger = screen.getByText("Pick a hero image");
(trigger.element() as HTMLElement).closest("button")!.click();
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
});
});
describe("selection", () => {
it("rewrites a local-provider item to the /_emdash/api/media/file/ URL", async () => {
const onChange = vi.fn();
const { screen } = await renderField({ placeholder: "open", onChange });
(screen.getByText("open").element() as HTMLElement).closest("button")!.click();
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
(screen.getByText("pick-local").element() as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith("hero", "/_emdash/api/media/file/photo.png");
});
it("uses the raw URL for items inserted via the URL tab (no provider, no storageKey)", async () => {
const onChange = vi.fn();
const { screen } = await renderField({ placeholder: "open", onChange });
(screen.getByText("open").element() as HTMLElement).closest("button")!.click();
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
(screen.getByText("pick-url").element() as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith("hero", "https://cdn.example/ext.jpg");
});
});
describe("preview", () => {
it("renders the image with no-referrer and lazy loading when value is a safe URL", async () => {
await renderField({ value: "/_emdash/api/media/file/abc.png" });
const img = await waitForImg();
expect(img.getAttribute("src")).toBe("/_emdash/api/media/file/abc.png");
expect(img.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(img.getAttribute("loading")).toBe("lazy");
});
it("renders the image for safe external URLs", async () => {
await renderField({ value: "https://cdn.example/img.png" });
const img = await waitForImg();
expect(img.getAttribute("src")).toBe("https://cdn.example/img.png");
});
it("falls back to the placeholder for javascript: URLs", async () => {
// Cast to any to bypass DOM-style typing on src; this string is what an
// admin user could paste into a text_input-compatible value.
const { screen } = await renderField({ value: "javascript:alert(1)" });
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
});
it("falls back to the placeholder for protocol-relative URLs", async () => {
const { screen } = await renderField({ value: "//evil.example/img.png" });
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
});
it("falls back to the placeholder for data: URIs", async () => {
const { screen } = await renderField({ value: "data:image/png;base64,iVBORw0KG" });
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
});
});
describe("remove", () => {
it("clears the value when Remove is clicked", async () => {
const onChange = vi.fn();
const { screen } = await renderField({
value: "/_emdash/api/media/file/abc.png",
onChange,
});
const removeBtn = screen.getByLabelText("Remove");
await expect.element(removeBtn).toBeInTheDocument();
(removeBtn.element() as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith("hero", "");
});
});
});

View File

@@ -0,0 +1,256 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CapabilityConsentDialog } from "../../src/components/CapabilityConsentDialog";
import { render } from "../utils/render.tsx";
describe("CapabilityConsentDialog", () => {
let onConfirm: ReturnType<typeof vi.fn>;
let onCancel: ReturnType<typeof vi.fn>;
beforeEach(() => {
onConfirm = vi.fn();
onCancel = vi.fn();
});
it("renders dialog with plugin name and capabilities", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="SEO Helper"
capabilities={["read:content", "write:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect
.element(screen.getByText("SEO Helper requires the following permissions:"))
.toBeInTheDocument();
await expect.element(screen.getByText("Read your content")).toBeInTheDocument();
await expect
.element(screen.getByText("Create, update, and delete content"))
.toBeInTheDocument();
});
it("shows 'Plugin Permissions' title for fresh install", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Plugin Permissions")).toBeInTheDocument();
});
it("shows 'Review New Permissions' title for update with new capabilities", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content", "write:content"]}
newCapabilities={["write:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Review New Permissions")).toBeInTheDocument();
await expect
.element(screen.getByText("Test is requesting additional permissions:"))
.toBeInTheDocument();
});
it("marks new capabilities with NEW badge", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content", "write:content", "network:fetch"]}
newCapabilities={["network:fetch"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
// The NEW badge should appear for network:fetch (exact match to avoid matching "New" in header)
const newBadges = screen.getByText("NEW", { exact: true }).all();
expect(newBadges.length).toBeGreaterThanOrEqual(1);
});
it("shows 'Accept & Install' button for fresh install", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Accept & Install")).toBeInTheDocument();
});
it("shows 'Accept & Update' button for update", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
newCapabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Accept & Update")).toBeInTheDocument();
});
it("calls onConfirm when confirm button is clicked", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await screen.getByText("Accept & Install").click();
expect(onConfirm).toHaveBeenCalledOnce();
});
it("calls onCancel when cancel button is clicked", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await screen.getByText("Cancel").click();
expect(onCancel).toHaveBeenCalledOnce();
});
it("shows warning banner for 'warn' audit verdict", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
auditVerdict="warn"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect
.element(screen.getByText("Security audit flagged potential concerns with this plugin."))
.toBeInTheDocument();
});
it("shows danger banner for 'fail' audit verdict", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
auditVerdict="fail"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect
.element(screen.getByText("Security audit flagged this plugin as potentially unsafe."))
.toBeInTheDocument();
});
it("shows no audit banner for 'pass' verdict", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
auditVerdict="pass"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
const warnText = screen.getByText(
"Security audit flagged potential concerns with this plugin.",
);
await expect.element(warnText).not.toBeInTheDocument();
});
it("shows pending state during install", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
isPending={true}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Installing...")).toBeInTheDocument();
});
it("shows pending state during update", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
newCapabilities={["read:content"]}
isPending={true}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("Updating...")).toBeInTheDocument();
});
it("appends allowed hosts for network:fetch", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["network:fetch"]}
allowedHosts={["api.example.com"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect
.element(screen.getByText("Make network requests to: api.example.com"))
.toBeInTheDocument();
});
it("renders raw capability string for unknown capabilities", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["custom:magic"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await expect.element(screen.getByText("custom:magic")).toBeInTheDocument();
});
it("has correct dialog role and aria attributes", async () => {
const screen = await render(
<CapabilityConsentDialog
pluginName="Test"
capabilities={["read:content"]}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
const dialog = screen.getByRole("dialog");
await expect.element(dialog).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,838 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ContentEditor,
type FieldDescriptor,
type ContentEditorProps,
} from "../../src/components/ContentEditor";
import type { ContentItem } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// Mock child components that have complex dependencies.
// The mock simulates the real editor's behaviour of freezing initial content on mount:
// it captures `value` once via useState initializer and never re-reads it.
// This is what makes the translation-switch bug observable in tests — the displayed
// content stays stale unless the component is forced to remount via a fresh `key`.
//
// It also mirrors the real component's onEditorReady contract: called with a stub
// editor on mount and with `null` on unmount, so consumers can clear stale refs
// before the next instance mounts.
let portableTextMountCount = 0;
type EditorReadyCall = { mockId: number | null };
let onEditorReadyCalls: EditorReadyCall[] = [];
vi.mock("../../src/components/PortableTextEditor", () => ({
PortableTextEditor: ({ value, placeholder, onEditorReady }: any) => {
// Mirror the real component: capture initial value once, never update.
const [initialValue] = React.useState(() => value);
const mountIdRef = React.useRef<number>(0);
React.useEffect(() => {
portableTextMountCount++;
mountIdRef.current = portableTextMountCount;
}, []);
React.useEffect(() => {
if (onEditorReady) {
const id = mountIdRef.current || portableTextMountCount + 1;
const stubEditor = { __mockId: id } as unknown;
onEditorReadyCalls.push({ mockId: id });
onEditorReady(stubEditor);
return () => {
onEditorReadyCalls.push({ mockId: null });
onEditorReady(null);
};
}
return undefined;
}, [onEditorReady]);
const text = Array.isArray(initialValue)
? initialValue
.map((b: any) => b?.children?.map((c: any) => c?.text ?? "").join("") ?? "")
.join("\n")
: "";
return (
<div data-testid="portable-text-editor" data-content={text}>
{placeholder}
</div>
);
},
}));
vi.mock("../../src/components/RevisionHistory", () => ({
RevisionHistory: () => <div data-testid="revision-history">Revision History</div>,
}));
vi.mock("../../src/components/TaxonomySidebar", () => ({
TaxonomySidebar: () => <div data-testid="taxonomy-sidebar">Taxonomy</div>,
}));
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DocumentOutline", () => ({
DocumentOutline: () => <div data-testid="doc-outline">Outline</div>,
}));
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
};
});
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
getPreviewUrl: vi.fn().mockResolvedValue({ url: "https://example.com/preview" }),
};
});
const defaultFields: Record<string, FieldDescriptor> = {
title: { kind: "string", label: "Title", required: true },
body: { kind: "string", label: "Body" },
};
const MOVE_TO_TRASH_PATTERN = /Move to Trash/i;
function makeItem(overrides: Partial<ContentItem> = {}): ContentItem {
return {
id: "item-1",
type: "posts",
slug: "my-post",
status: "draft",
data: { title: "My Post", body: "Some content" },
authorId: null,
createdAt: "2025-01-15T10:30:00Z",
updatedAt: "2025-01-15T10:30:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
...overrides,
};
}
function renderEditor(props: Partial<ContentEditorProps> = {}) {
const defaultProps: ContentEditorProps = {
collection: "posts",
collectionLabel: "Post",
fields: defaultFields,
isNew: true,
onSave: vi.fn(),
...props,
};
return render(<ContentEditor {...defaultProps} />);
}
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
portableTextMountCount = 0;
onEditorReadyCalls = [];
});
describe("slug generation", () => {
it("auto-generates slug from title for new items", async () => {
const screen = await renderEditor({ isNew: true });
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("Hello World Post");
const slugInput = screen.getByLabelText("Slug");
await expect.element(slugInput).toHaveValue("hello-world-post");
});
it("slug accepts manual override", async () => {
const screen = await renderEditor({ isNew: true });
const slugInput = screen.getByLabelText("Slug");
await slugInput.fill("custom-slug");
await expect.element(slugInput).toHaveValue("custom-slug");
// After manual edit, typing in title should NOT update slug
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("New Title");
await expect.element(slugInput).toHaveValue("custom-slug");
});
it("slug is editable for new items", async () => {
const screen = await renderEditor({ isNew: true });
const slugInput = screen.getByLabelText("Slug");
await expect.element(slugInput).toBeEnabled();
});
});
describe("field rendering", () => {
it("renders string fields as text inputs", async () => {
const screen = await renderEditor({
fields: { title: { kind: "string", label: "Title" } },
});
const input = screen.getByLabelText("Title");
await expect.element(input).toBeInTheDocument();
});
it("renders boolean fields as switches", async () => {
const screen = await renderEditor({
fields: { featured: { kind: "boolean", label: "Featured" } },
});
const toggle = screen.getByRole("switch");
await expect.element(toggle).toBeInTheDocument();
});
it("renders number fields as number inputs", async () => {
const screen = await renderEditor({
fields: { order: { kind: "number", label: "Order" } },
});
const input = screen.getByLabelText("Order");
await expect.element(input).toHaveAttribute("type", "number");
});
it("renders select fields as select dropdowns", async () => {
const screen = await renderEditor({
fields: {
color: {
kind: "select",
label: "Color",
options: [
{ value: "red", label: "Red" },
{ value: "blue", label: "Blue" },
],
},
},
});
// Select renders a combobox role
const select = screen.getByRole("combobox");
await expect.element(select).toBeInTheDocument();
});
it("renders multiSelect fields as checkbox group", async () => {
const screen = await renderEditor({
fields: {
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});
const checkboxes = screen.getByRole("checkbox", { exact: false });
await expect.element(checkboxes.first()).toBeInTheDocument();
// All option labels should be present
await expect.element(screen.getByText("News")).toBeInTheDocument();
await expect.element(screen.getByText("Tech")).toBeInTheDocument();
await expect.element(screen.getByText("Sports")).toBeInTheDocument();
});
it("toggling a multiSelect checkbox updates the saved value", async () => {
const onSave = vi.fn();
const item = makeItem({
data: { title: "Test", tags: ["news", "sports"] },
});
const screen = await renderEditor({
isNew: false,
item,
onSave,
fields: {
title: { kind: "string", label: "Title", required: true },
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});
const checkboxes = screen.getByRole("checkbox", { exact: false });
const all = checkboxes.all();
// Uncheck "sports" (index 2, currently checked)
await all[2]!.click();
await expect.element(all[2]!).not.toBeChecked();
// Check "tech" (index 1, currently unchecked)
await all[1]!.click();
await expect.element(all[1]!).toBeChecked();
// Save and verify the data sent to onSave
const saveBtn = screen.getByRole("button", { name: "Save" });
await saveBtn.click();
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tags: ["news", "tech"],
}),
}),
);
});
it("multiSelect checkboxes reflect existing values", async () => {
const item = makeItem({
data: { title: "Test", tags: ["news", "sports"] },
});
const screen = await renderEditor({
isNew: false,
item,
fields: {
title: { kind: "string", label: "Title", required: true },
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});
// Verify the checkbox group renders with correct checked state via aria
const checkboxes = screen.getByRole("checkbox", { exact: false });
const all = checkboxes.all();
// Should have 3 checkboxes
expect(all).toHaveLength(3);
// news (checked), tech (unchecked), sports (checked)
await expect.element(all[0]!).toBeChecked();
await expect.element(all[1]!).not.toBeChecked();
await expect.element(all[2]!).toBeChecked();
});
it("renders datetime fields as datetime-local inputs", async () => {
const screen = await renderEditor({
fields: { recall_date: { kind: "datetime", label: "Recall date" } },
});
const input = screen.getByLabelText("Recall date");
await expect.element(input).toHaveAttribute("type", "datetime-local");
});
it("displays a stored ISO datetime in the datetime-local input", async () => {
// The validator stores datetimes as full ISO 8601 with "Z" + millis,
// but <input type="datetime-local"> only accepts "YYYY-MM-DDTHH:mm".
// Without conversion the browser silently renders an empty input.
const item = makeItem({
data: { title: "Recall", recall_date: "2026-02-26T09:30:00.000Z" },
});
const screen = await renderEditor({
isNew: false,
item,
fields: {
title: { kind: "string", label: "Title", required: true },
recall_date: { kind: "datetime", label: "Recall date" },
},
});
const input = screen.getByLabelText("Recall date");
await expect.element(input).toHaveValue("2026-02-26T09:30");
});
it("saves datetime fields back as full ISO 8601 with Z and milliseconds", async () => {
// datetime-local emits "YYYY-MM-DDTHH:mm" which the field's
// `z.string().datetime().or(z.string().date())` schema rejects.
// The widget must round-trip the value back to a validator-accepted shape.
const onSave = vi.fn();
const screen = await renderEditor({
isNew: true,
onSave,
fields: {
title: { kind: "string", label: "Title", required: true },
recall_date: { kind: "datetime", label: "Recall date" },
},
});
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("Recall");
const dtInput = screen.getByLabelText("Recall date");
await dtInput.fill("2026-02-26T09:30");
const saveBtn = screen.getByRole("button", { name: "Save" });
await saveBtn.click();
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
recall_date: "2026-02-26T09:30:00.000Z",
}),
}),
);
});
it("renders json fields as a textarea", async () => {
const screen = await renderEditor({
fields: { metadata: { kind: "json", label: "Metadata" } },
isNew: true,
});
const textarea = screen.getByLabelText("Metadata");
await expect.element(textarea).toBeInTheDocument();
// JSON field uses a textarea element
expect(textarea.element().tagName).toBe("TEXTAREA");
});
it("renders json fields with object values as formatted JSON", async () => {
const jsonData = { foo: "bar", num: 42 };
const screen = await renderEditor({
fields: { metadata: { kind: "json", label: "Metadata" } },
item: makeItem({ data: { title: "Test", body: "", metadata: jsonData } }),
});
const textarea = screen.getByLabelText("Metadata");
await expect.element(textarea).toHaveValue(JSON.stringify(jsonData, null, 2));
});
});
describe("saving", () => {
it("save form calls onSave with formData including slug", async () => {
const onSave = vi.fn();
const screen = await renderEditor({ isNew: true, onSave });
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("Test Title");
const saveBtn = screen.getByRole("button", { name: "Save" });
await saveBtn.click();
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ title: "Test Title" }),
slug: "test-title",
bylines: [],
}),
);
});
it("SaveButton shows correct dirty state for new items", async () => {
const screen = await renderEditor({ isNew: true });
// New items are always dirty
const saveBtn = screen.getByRole("button", { name: "Save" });
await expect.element(saveBtn).toBeEnabled();
});
it("SaveButton is disabled (Saved) for existing item with no changes", async () => {
const item = makeItem();
const screen = await renderEditor({ isNew: false, item });
const savedBtn = screen.getByRole("button", { name: "Saved" });
await expect.element(savedBtn).toBeDisabled();
});
it("keeps edited values after autosave completes without queuing another autosave", async () => {
vi.useFakeTimers();
try {
const item = makeItem();
const onAutosave = vi.fn();
const props: ContentEditorProps = {
collection: "posts",
collectionLabel: "Post",
fields: defaultFields,
isNew: false,
item,
onSave: vi.fn(),
onAutosave,
isAutosaving: false,
lastAutosaveAt: null,
};
const screen = await render(<ContentEditor {...props} />);
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("Updated title");
await vi.advanceTimersByTimeAsync(2000);
expect(onAutosave).toHaveBeenCalledTimes(1);
await screen.rerender(<ContentEditor {...props} isAutosaving={true} />);
const autosavedItem = makeItem({
updatedAt: "2026-04-12T18:38:00Z",
data: { title: "Updated title", body: "Some content" },
});
await screen.rerender(
<ContentEditor
{...props}
item={autosavedItem}
isAutosaving={false}
lastAutosaveAt={new Date("2026-04-12T18:38:00Z")}
/>,
);
await expect.element(screen.getByLabelText("Title")).toHaveValue("Updated title");
await vi.advanceTimersByTimeAsync(2500);
expect(onAutosave).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
describe("delete", () => {
it("shows delete button for existing items", async () => {
const item = makeItem();
const onDelete = vi.fn();
const screen = await renderEditor({ isNew: false, item, onDelete });
const deleteBtn = screen.getByRole("button", { name: MOVE_TO_TRASH_PATTERN });
await expect.element(deleteBtn).toBeInTheDocument();
});
it("delete button opens confirmation dialog and confirming calls onDelete", async () => {
const item = makeItem();
const onDelete = vi.fn();
const screen = await renderEditor({ isNew: false, item, onDelete });
// Click the delete trigger button
const deleteBtn = screen.getByRole("button", { name: MOVE_TO_TRASH_PATTERN });
await deleteBtn.click();
// Dialog should appear with "Move to Trash?" title
await expect.element(screen.getByText("Move to Trash?")).toBeInTheDocument();
// There are multiple "Move to Trash" buttons - click the last one (the dialog confirm)
const allBtns = document.querySelectorAll("button");
const trashBtns = [...allBtns].filter((b) => b.textContent?.trim() === "Move to Trash");
if (trashBtns[1]) {
trashBtns[1].click();
}
await vi.waitFor(() => {
expect(onDelete).toHaveBeenCalled();
});
});
it("does not show delete button for new items", async () => {
const screen = await renderEditor({ isNew: true });
await expect
.element(screen.getByText("Move to Trash"), { timeout: 100 })
.not.toBeInTheDocument();
});
});
describe("publish actions", () => {
it("shows Publish button for draft items", async () => {
const item = makeItem({ status: "draft" });
const onPublish = vi.fn();
const screen = await renderEditor({ isNew: false, item, onPublish });
const publishBtn = screen.getByRole("button", { name: "Publish" });
await expect.element(publishBtn).toBeInTheDocument();
});
it("publish button calls onPublish", async () => {
const item = makeItem({ status: "draft" });
const onPublish = vi.fn();
const screen = await renderEditor({ isNew: false, item, onPublish });
const publishBtn = screen.getByRole("button", { name: "Publish" });
await publishBtn.click();
expect(onPublish).toHaveBeenCalled();
});
it("shows Unpublish for published items with supportsDrafts", async () => {
const item = makeItem({
status: "published",
liveRevisionId: "rev-1",
draftRevisionId: "rev-1",
});
const onUnpublish = vi.fn();
const screen = await renderEditor({
isNew: false,
item,
onUnpublish,
supportsDrafts: true,
});
const unpublishBtn = screen.getByRole("button", { name: "Unpublish" });
await expect.element(unpublishBtn).toBeInTheDocument();
});
it("unpublish button calls onUnpublish", async () => {
const item = makeItem({
status: "published",
liveRevisionId: "rev-1",
draftRevisionId: "rev-1",
});
const onUnpublish = vi.fn();
const screen = await renderEditor({
isNew: false,
item,
onUnpublish,
supportsDrafts: true,
});
const unpublishBtn = screen.getByRole("button", { name: "Unpublish" });
await unpublishBtn.click();
expect(onUnpublish).toHaveBeenCalled();
});
});
describe("distraction-free mode", () => {
it("toggle adds fixed class for distraction-free mode", async () => {
const screen = await renderEditor({ isNew: true });
const enterBtn = screen.getByRole("button", { name: "Enter distraction-free mode" });
await enterBtn.click();
// The form should now have the fixed inset-0 class
const form = document.querySelector("form");
expect(form?.classList.toString()).toContain("fixed");
});
it("escape exits distraction-free mode", async () => {
const screen = await renderEditor({ isNew: true });
const enterBtn = screen.getByRole("button", { name: "Enter distraction-free mode" });
await enterBtn.click();
// Verify we're in distraction-free mode
let form = document.querySelector("form");
expect(form?.classList.toString()).toContain("fixed");
// Press Escape
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
// Wait for the state to update
await vi.waitFor(() => {
form = document.querySelector("form");
expect(form?.classList.toString()).not.toContain("fixed");
});
});
});
describe("scheduler", () => {
it("shows scheduler when Schedule for later is clicked", async () => {
const item = makeItem({ status: "draft" });
const onSchedule = vi.fn();
const screen = await renderEditor({ isNew: false, item, onSchedule });
const scheduleBtn = screen.getByRole("button", { name: "Schedule for later" });
await scheduleBtn.click();
// Should now show the datetime input
await expect.element(screen.getByLabelText("Schedule for")).toBeInTheDocument();
// And a Schedule submit button
await expect.element(screen.getByRole("button", { name: "Schedule" })).toBeInTheDocument();
});
it("shows Publish button for scheduled items", async () => {
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
const onPublish = vi.fn();
const screen = await renderEditor({ isNew: false, item, onPublish });
const publishBtn = screen.getByRole("button", { name: "Publish" });
await expect.element(publishBtn).toBeInTheDocument();
});
it("publish button on scheduled item calls onPublish", async () => {
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
const onPublish = vi.fn();
const screen = await renderEditor({ isNew: false, item, onPublish });
const publishBtn = screen.getByRole("button", { name: "Publish" });
await publishBtn.click();
expect(onPublish).toHaveBeenCalled();
});
it("shows Unschedule button in sidebar for scheduled items", async () => {
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
const onUnschedule = vi.fn();
const screen = await renderEditor({ isNew: false, item, onUnschedule });
// Unschedule should be in the sidebar, not in the header
const unscheduleBtn = screen.getByRole("button", { name: "Unschedule" });
await expect.element(unscheduleBtn).toBeInTheDocument();
});
it("unschedule button calls onUnschedule", async () => {
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
const onUnschedule = vi.fn();
const screen = await renderEditor({ isNew: false, item, onUnschedule });
const unscheduleBtn = screen.getByRole("button", { name: "Unschedule" });
await unscheduleBtn.click();
expect(onUnschedule).toHaveBeenCalled();
});
});
describe("heading", () => {
it("shows 'New Post' heading for new items", async () => {
const screen = await renderEditor({ isNew: true, collectionLabel: "Post" });
await expect.element(screen.getByText("New Post")).toBeInTheDocument();
});
it("shows 'Edit Post' heading for existing items", async () => {
const item = makeItem();
const screen = await renderEditor({ isNew: false, item, collectionLabel: "Post" });
await expect.element(screen.getByText("Edit Post")).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Bug: translation switch leaves stale content in PortableTextEditor.
//
// When navigating between translations of the same content (e.g. /en post ->
// /fr post), TanStack Router keeps ContentEditor mounted and only the `item`
// prop changes. The PortableTextEditor (TipTap) freezes its content via
// useMemo([], ...) on mount and has no effect to reconcile incoming `value`
// changes, so it keeps showing the previous locale's body.
//
// Worse: any subsequent edit fires onUpdate with the stale content, silently
// overwriting the new translation's body in formData.
//
// Fix: key <FieldRenderer> by `${name}:${item?.id ?? "new"}` so all field
// editors remount cleanly when the underlying content item changes.
// ---------------------------------------------------------------------------
describe("translation / item switch", () => {
function buildPtItem(id: string, text: string): ContentItem {
return makeItem({
id,
slug: id,
data: {
title: text,
body: [
{
_type: "block",
_key: `block-${id}`,
style: "normal",
children: [{ _type: "span", _key: `span-${id}`, text, marks: [] }],
markDefs: [],
},
],
},
});
}
const ptFields: Record<string, FieldDescriptor> = {
title: { kind: "string", label: "Title", required: true },
body: { kind: "portableText", label: "Body" },
};
it("remounts the portable text editor when item.id changes (translation switch)", async () => {
const itemEn = buildPtItem("post-en", "English body");
const itemFr = buildPtItem("post-fr", "French body");
// Use a wrapper so we can swap items without unmounting ContentEditor.
function Switcher({ item }: { item: ContentItem }) {
return (
<ContentEditor
collection="posts"
collectionLabel="Post"
fields={ptFields}
isNew={false}
item={item}
onSave={vi.fn()}
/>
);
}
const screen = await render(<Switcher item={itemEn} />);
// Initial mount: editor shows the English body.
const editor = screen.getByTestId("portable-text-editor");
await expect.element(editor).toHaveAttribute("data-content", "English body");
expect(portableTextMountCount).toBe(1);
// Simulate translation switch by rerendering with a different item id.
// The fix (keying FieldRenderer by item.id) must force a fresh mount
// so the editor reads the new locale's body.
await screen.rerender(<Switcher item={itemFr} />);
const editorAfter = screen.getByTestId("portable-text-editor");
await expect.element(editorAfter).toHaveAttribute("data-content", "French body");
// A new mount means the FieldRenderer was keyed by id and remounted.
// Without the fix, mountCount stays at 1 and content stays stale.
expect(portableTextMountCount).toBeGreaterThanOrEqual(2);
});
it("wires onEditorReady through for the 'content' field so DocumentOutline tracks remounts", async () => {
// ContentEditor only wires `onEditorReady` to its `setPortableTextEditor`
// slot when the field name is exactly "content" (see ContentEditor.tsx,
// where the conditional onEditorReady prop is set). On a translation
// switch, the FieldRenderer is keyed by item.id so the editor remounts;
// the corresponding cleanup call flows through, clearing the stale ref
// in the parent before the new instance mounts.
//
// The actual cleanup behaviour of PortableTextEditor (calling
// onEditorReady(null) on unmount) is exercised against the real
// component in tests/editor/PortableTextEditor.test.tsx — this test
// only verifies that ContentEditor wires the callback in the first place.
const ptFieldsForContent: Record<string, FieldDescriptor> = {
title: { kind: "string", label: "Title", required: true },
// "content" is the magic field name that wires onEditorReady through
// to ContentEditor's setPortableTextEditor (see ContentEditor.tsx).
content: { kind: "portableText", label: "Body" },
};
const itemEn = makeItem({
id: "post-en",
slug: "post-en",
data: {
title: "EN",
content: [
{
_type: "block",
_key: "block-en",
style: "normal",
children: [{ _type: "span", _key: "span-en", text: "English", marks: [] }],
markDefs: [],
},
],
},
});
const itemFr = makeItem({
id: "post-fr",
slug: "post-fr",
data: {
title: "FR",
content: [
{
_type: "block",
_key: "block-fr",
style: "normal",
children: [{ _type: "span", _key: "span-fr", text: "French", marks: [] }],
markDefs: [],
},
],
},
});
function Switcher({ item }: { item: ContentItem }) {
return (
<ContentEditor
collection="posts"
collectionLabel="Post"
fields={ptFieldsForContent}
isNew={false}
item={item}
onSave={vi.fn()}
/>
);
}
const screen = await render(<Switcher item={itemEn} />);
await expect
.element(screen.getByTestId("portable-text-editor"))
.toHaveAttribute("data-content", "English");
// Initial mount fired exactly one onEditorReady call with a non-null editor.
expect(onEditorReadyCalls).toHaveLength(1);
expect(onEditorReadyCalls[0]?.mockId).not.toBeNull();
await screen.rerender(<Switcher item={itemFr} />);
await expect
.element(screen.getByTestId("portable-text-editor"))
.toHaveAttribute("data-content", "French");
// After the switch we expect the call sequence:
// 1. mount (en) -> non-null
// 2. cleanup (en) -> null <-- the M1 fix
// 3. mount (fr) -> non-null
// Without the cleanup in PortableTextEditor's onEditorReady effect,
// step 2 is missing and the stale en-editor reference lingers in
// ContentEditor's state during the remount window.
const nullCallIndex = onEditorReadyCalls.findIndex((c) => c.mockId === null);
expect(nullCallIndex).toBeGreaterThan(-1);
// The null call must come before the final mount (otherwise the slot
// would end up null after a fresh editor was reported ready).
const lastCall = onEditorReadyCalls.at(-1);
expect(lastCall?.mockId).not.toBeNull();
expect(nullCallIndex).toBeLessThan(onEditorReadyCalls.length - 1);
});
});
});

View File

@@ -0,0 +1,522 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentList } from "../../src/components/ContentList";
import type { ContentItem, TrashedContentItem } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
const NO_RESULTS_PATTERN = /No results for/;
const HAS_MORE_ITEMS_PATTERN = /21\+ items/;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NO_POSTS_YET_REGEX = /No posts yet/;
const MOVE_TO_TRASH_CONFIRMATION_REGEX = /Move "Post" to trash/;
const PERMANENT_DELETE_CONFIRMATION_REGEX = /Permanently delete "Old Post"/;
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({
children,
to,
params: _params,
...props
}: {
children: React.ReactNode;
to?: string;
params?: Record<string, string>;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
};
});
function makeItem(overrides: Partial<ContentItem> = {}): ContentItem {
return {
id: "item_01",
type: "posts",
slug: "hello-world",
status: "draft",
data: { title: "Hello World" },
authorId: "user_01",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: "rev_01",
...overrides,
};
}
function makeTrashedItem(overrides: Partial<TrashedContentItem> = {}): TrashedContentItem {
return {
...makeItem(),
deletedAt: "2025-01-03T00:00:00Z",
...overrides,
};
}
const defaultProps = {
collection: "posts",
collectionLabel: "Posts",
items: [] as ContentItem[],
};
describe("ContentList", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("rendering items", () => {
it("renders items in table with data.title", async () => {
const items = [makeItem({ id: "1", data: { title: "My Post" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("My Post")).toBeInTheDocument();
});
it("falls back to data.name when title is missing", async () => {
const items = [makeItem({ id: "1", data: { name: "Named Item" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("Named Item")).toBeInTheDocument();
});
it("falls back to slug when title and name are missing", async () => {
const items = [makeItem({ id: "1", slug: "my-slug", data: {} })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("my-slug")).toBeInTheDocument();
});
it("falls back to id when title, name, and slug are missing", async () => {
const items = [makeItem({ id: "item_xyz", slug: null, data: {} })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("item_xyz")).toBeInTheDocument();
});
it("renders multiple items", async () => {
const items = [
makeItem({ id: "1", data: { title: "First" } }),
makeItem({ id: "2", data: { title: "Second" } }),
makeItem({ id: "3", data: { title: "Third" } }),
];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("First")).toBeInTheDocument();
await expect.element(screen.getByText("Second")).toBeInTheDocument();
await expect.element(screen.getByText("Third")).toBeInTheDocument();
});
});
describe("empty states", () => {
it("shows empty message for All tab", async () => {
const screen = await render(<ContentList {...defaultProps} items={[]} />);
await expect.element(screen.getByText(NO_POSTS_YET_REGEX)).toBeInTheDocument();
await expect.element(screen.getByText("Create your first one")).toBeInTheDocument();
});
it("shows empty trash message in Trash tab", async () => {
const screen = await render(<ContentList {...defaultProps} items={[]} trashedItems={[]} />);
// Switch to Trash tab
await screen.getByText("Trash").click();
await expect.element(screen.getByText("Trash is empty")).toBeInTheDocument();
});
});
describe("tab switching", () => {
it("defaults to All tab", async () => {
const items = [makeItem()];
const screen = await render(<ContentList {...defaultProps} items={items} />);
// Items should be visible (All tab active)
await expect.element(screen.getByText("Hello World")).toBeInTheDocument();
});
it("switches to Trash tab", async () => {
const trashed = [
makeTrashedItem({
id: "t1",
data: { title: "Deleted Post" },
}),
];
const screen = await render(
<ContentList {...defaultProps} items={[makeItem()]} trashedItems={trashed} />,
);
await screen.getByText("Trash").click();
await expect.element(screen.getByText("Deleted Post")).toBeInTheDocument();
});
it("shows trash count badge when items are trashed", async () => {
const screen = await render(
<ContentList {...defaultProps} items={[]} trashedItems={[]} trashedCount={42} />,
);
await expect.element(screen.getByText("42")).toBeInTheDocument();
});
});
describe("status badges", () => {
it("shows draft status", async () => {
const items = [makeItem({ id: "1", status: "draft" })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("draft")).toBeInTheDocument();
});
it("shows published status", async () => {
const items = [makeItem({ id: "1", status: "published" })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("published")).toBeInTheDocument();
});
it("shows pending badge when draftRevisionId differs from liveRevisionId", async () => {
const items = [
makeItem({
id: "1",
status: "published",
draftRevisionId: "rev_draft",
liveRevisionId: "rev_live",
}),
];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("pending")).toBeInTheDocument();
});
it("does not show pending badge when revisions match", async () => {
const items = [
makeItem({
id: "1",
status: "published",
draftRevisionId: "rev_same",
liveRevisionId: "rev_same",
}),
];
const screen = await render(<ContentList {...defaultProps} items={items} />);
expect(screen.getByText("pending").query()).toBeNull();
});
});
describe("delete confirmation", () => {
it("shows delete confirmation dialog with item title", async () => {
const onDelete = vi.fn();
const items = [makeItem({ id: "item_1", data: { title: "Post" } })];
const screen = await render(
<ContentList {...defaultProps} items={items} onDelete={onDelete} />,
);
// Click trash icon button to open the confirmation dialog
await screen.getByRole("button", { name: "Move Post to trash" }).click();
// Dialog should appear with confirmation text
await expect.element(screen.getByText("Move to Trash?")).toBeInTheDocument();
await expect.element(screen.getByText(MOVE_TO_TRASH_CONFIRMATION_REGEX)).toBeInTheDocument();
// Confirm and Cancel buttons should be visible
await expect
.element(screen.getByRole("button", { name: "Move to Trash" }))
.toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
});
describe("permanent delete", () => {
it("shows permanent delete dialog with item title", async () => {
const onPermanentDelete = vi.fn();
const trashed = [
makeTrashedItem({
id: "t1",
data: { title: "Old Post" },
}),
];
const screen = await render(
<ContentList
{...defaultProps}
items={[]}
trashedItems={trashed}
onPermanentDelete={onPermanentDelete}
/>,
);
// Switch to trash tab
await screen.getByText("Trash").click();
// Click permanent delete trigger button
await screen.getByRole("button", { name: "Permanently delete Old Post" }).click();
// Dialog should appear with correct text
await expect.element(screen.getByText("Delete Permanently?")).toBeInTheDocument();
await expect
.element(screen.getByText(PERMANENT_DELETE_CONFIRMATION_REGEX))
.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: "Delete Permanently" }))
.toBeInTheDocument();
});
});
describe("restore", () => {
it("calls onRestore when restore button is clicked", async () => {
const onRestore = vi.fn();
const trashed = [
makeTrashedItem({
id: "t1",
data: { title: "Restorable" },
}),
];
const screen = await render(
<ContentList {...defaultProps} items={[]} trashedItems={trashed} onRestore={onRestore} />,
);
await screen.getByText("Trash").click();
await screen.getByRole("button", { name: "Restore Restorable" }).click();
expect(onRestore).toHaveBeenCalledWith("t1");
});
});
describe("duplicate", () => {
it("calls onDuplicate when duplicate button is clicked", async () => {
const onDuplicate = vi.fn();
const items = [makeItem({ id: "item_1", data: { title: "Copyable" } })];
const screen = await render(
<ContentList {...defaultProps} items={items} onDuplicate={onDuplicate} />,
);
await screen.getByRole("button", { name: "Duplicate Copyable" }).click();
expect(onDuplicate).toHaveBeenCalledWith("item_1");
});
});
describe("load more", () => {
it("shows Load More button when hasMore is true", async () => {
const onLoadMore = vi.fn();
const items = [makeItem()];
const screen = await render(
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
);
await expect.element(screen.getByRole("button", { name: "Load More" })).toBeInTheDocument();
});
it("does not show Load More when hasMore is false", async () => {
const items = [makeItem()];
const screen = await render(<ContentList {...defaultProps} items={items} hasMore={false} />);
expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull();
});
it("auto-fetches when user navigates to the last client-side page", async () => {
const onLoadMore = vi.fn();
// 21 items = 2 pages of 20; user starts on page 0 (not the last page)
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
const screen = await render(
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
);
// On mount, page 0 is not the last page — no fetch yet
expect(onLoadMore).not.toHaveBeenCalled();
// Navigate to page 2 (the last page)
await screen.getByRole("button", { name: "Next page" }).click();
expect(onLoadMore).toHaveBeenCalledOnce();
});
it("does not auto-fetch when a search query is active", async () => {
const onLoadMore = vi.fn();
// 21 items so pagination exists, but search will collapse to 1 result / 1 page
const items = [
...Array.from({ length: 20 }, (_, i) =>
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
),
makeItem({ id: "unique", data: { title: "Unique Title" } }),
];
const screen = await render(
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
);
// No fetch on mount (page 0 is not the last page with 21 items)
expect(onLoadMore).not.toHaveBeenCalled();
// Search collapses results to 1 item — totalPages becomes 1, but should NOT fetch
await screen.getByRole("searchbox").fill("Unique Title");
expect(onLoadMore).not.toHaveBeenCalled();
});
it("shows '+' suffix on item count when hasMore is true and no search is active", async () => {
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
const screen = await render(<ContentList {...defaultProps} items={items} hasMore={true} />);
await expect.element(screen.getByText(HAS_MORE_ITEMS_PATTERN)).toBeInTheDocument();
});
it("calls onLoadMore when Load More is clicked", async () => {
const onLoadMore = vi.fn();
const items = [makeItem()];
const screen = await render(
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
);
// With 1 item and hasMore=true, the auto-fetch effect fires on mount
// because page 0 is already the last client-side page.
// The button click adds a second call on top of that.
expect(onLoadMore).toHaveBeenCalledOnce();
await screen.getByRole("button", { name: "Load More" }).click();
expect(onLoadMore).toHaveBeenCalledTimes(2);
});
});
describe("header", () => {
it("shows collection label as heading", async () => {
const screen = await render(<ContentList {...defaultProps} collectionLabel="Articles" />);
await expect.element(screen.getByRole("heading", { name: "Articles" })).toBeInTheDocument();
});
it("shows Add New link", async () => {
const screen = await render(<ContentList {...defaultProps} />);
await expect.element(screen.getByText("Add New")).toBeInTheDocument();
});
});
describe("search", () => {
it("shows search input when items exist", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect
.element(screen.getByRole("searchbox", { name: "Search posts" }))
.toBeInTheDocument();
});
it("hides search input when no items", async () => {
const screen = await render(<ContentList {...defaultProps} items={[]} />);
expect(screen.getByRole("searchbox").query()).toBeNull();
});
it("filters items by title", async () => {
const items = [
makeItem({ id: "1", data: { title: "Alpha post" } }),
makeItem({ id: "2", data: { title: "Beta post" } }),
makeItem({ id: "3", data: { title: "Gamma post" } }),
];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await screen.getByRole("searchbox").fill("beta");
await expect.element(screen.getByText("Beta post")).toBeInTheDocument();
expect(screen.getByText("Alpha post").query()).toBeNull();
expect(screen.getByText("Gamma post").query()).toBeNull();
});
it("shows no results message when search has no matches", async () => {
const items = [makeItem({ id: "1", data: { title: "Hello" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
await screen.getByRole("searchbox").fill("zzzzz");
await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument();
});
});
describe("pagination", () => {
it("shows pagination when items exceed page size", async () => {
const items = Array.from({ length: 25 }, (_, i) =>
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
);
const screen = await render(<ContentList {...defaultProps} items={items} />);
await expect.element(screen.getByText("1 / 2")).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Next page" })).toBeInTheDocument();
});
it("does not show pagination when items fit on one page", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
expect(screen.getByRole("button", { name: "Next page" }).query()).toBeNull();
});
it("navigates between pages", async () => {
const items = Array.from({ length: 25 }, (_, i) =>
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
);
const screen = await render(<ContentList {...defaultProps} items={items} />);
// Page 1 should show Post 0
await expect.element(screen.getByText("Post 0")).toBeInTheDocument();
// Go to page 2
await screen.getByRole("button", { name: "Next page" }).click();
await expect.element(screen.getByText("2 / 2")).toBeInTheDocument();
// Post 20 should be on page 2
await expect.element(screen.getByText("Post 20")).toBeInTheDocument();
// Post 0 should not be visible
expect(screen.getByText("Post 0").query()).toBeNull();
});
});
describe("sortable headers", () => {
it("calls onSortChange when a header is clicked", async () => {
const onSortChange = vi.fn();
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "updatedAt", direction: "desc" }}
onSortChange={onSortChange}
/>,
);
await screen.getByRole("button", { name: "Title" }).click();
expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "desc" });
});
it("toggles direction when clicking the active column", async () => {
const onSortChange = vi.fn();
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "title", direction: "desc" }}
onSortChange={onSortChange}
/>,
);
await screen.getByRole("button", { name: "Title" }).click();
expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "asc" });
});
it("exposes sort state via aria-sort on the active header", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "title", direction: "asc" }}
onSortChange={vi.fn()}
/>,
);
const titleHeader = screen.getByRole("columnheader", { name: "Title" });
const statusHeader = screen.getByRole("columnheader", { name: "Status" });
await expect.element(titleHeader).toHaveAttribute("aria-sort", "ascending");
// Inactive columns explicitly advertise "none" so the header still
// announces as sortable.
await expect.element(statusHeader).toHaveAttribute("aria-sort", "none");
});
it("falls back to static headers when onSortChange is not provided", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);
// The header must not render as a button — it's just a label.
expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull();
});
});
});

View File

@@ -0,0 +1,589 @@
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import {
ContentTypeEditor,
type ContentTypeEditorProps,
} from "../../src/components/ContentTypeEditor";
import type { SchemaCollectionWithFields, SchemaField } from "../../src/lib/api";
import { render } from "../utils/render";
// Regexes hoisted to module scope to avoid recompilation per call
const EDIT_TITLE_RE = /Edit Title field/i;
const EDIT_BODY_RE = /Edit Body field/i;
const URL_PATTERN_SLUG_RE = /must include.*\{slug\}/i;
// Mock tanstack router — Link renders as <a>, useNavigate is a no-op
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
useNavigate: () => vi.fn(),
};
});
// Mock FieldEditor — just expose open state via data attribute
vi.mock("../../src/components/FieldEditor", () => ({
FieldEditor: ({ open }: { open: boolean }) =>
open ? <div data-testid="field-editor-dialog">Field Editor</div> : null,
}));
const DELETE_FIELD_BUTTON_PATTERN = /Delete Title field/i;
function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
return {
id: "field-1",
collectionId: "col-1",
slug: "title",
label: "Title",
type: "string",
columnType: "TEXT",
required: false,
unique: false,
searchable: false,
sortOrder: 0,
createdAt: "2025-01-01T00:00:00Z",
...overrides,
};
}
function makeCollection(
overrides: Partial<SchemaCollectionWithFields> = {},
): SchemaCollectionWithFields {
return {
id: "col-1",
slug: "posts",
label: "Posts",
labelSingular: "Post",
description: "Blog posts",
supports: ["drafts"],
fields: [],
hasSeo: false,
commentsEnabled: false,
commentsModeration: "first_time",
commentsClosedAfterDays: 90,
commentsAutoApproveUsers: true,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
};
}
const noop = () => {};
function defaultProps(overrides: Partial<ContentTypeEditorProps> = {}): ContentTypeEditorProps {
return {
onSave: noop,
onAddField: noop,
onUpdateField: noop,
onDeleteField: noop,
onReorderFields: noop,
...overrides,
};
}
const DRAFTS_CHECKBOX_REGEX = /Drafts/i;
const REVISIONS_CHECKBOX_REGEX = /Revisions/i;
const SAVE_CHANGES_BUTTON_REGEX = /Save Changes/i;
const CREATE_CONTENT_TYPE_BUTTON_REGEX = /Create Content Type/i;
const EDIT_FIELD_BUTTON_REGEX = /Edit .* field/i;
const DELETE_FIELD_BUTTON_REGEX = /Delete .* field/i;
const ADD_FIELD_BUTTON_REGEX = /Add Field/i;
const SAVING_BUTTON_REGEX = /Saving/i;
const CODE_DEFINED_MSG_REGEX = /This collection is defined in code/i;
const SYSTEM_FIELDS_REGEX = /6 system \+ 2 custom fields/;
describe("ContentTypeEditor", () => {
// ---- Title for new vs edit mode ----
it("shows 'New Content Type' title when isNew", async () => {
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
await expect.element(screen.getByText("New Content Type")).toBeInTheDocument();
});
it("shows collection label as title when editing", async () => {
const collection = makeCollection({ label: "Articles" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await expect.element(screen.getByText("Articles")).toBeInTheDocument();
});
// ---- Auto-slug from label when isNew ----
it("auto-generates slug from label when isNew", async () => {
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
const labelInput = screen.getByLabelText("Label (Plural)");
await labelInput.fill("Blog Posts");
// The slug input should auto-populate from the label
const slugInput = screen.getByLabelText("Slug");
await expect.element(slugInput).toHaveValue("blog_posts");
});
// ---- Slug disabled when editing ----
it("does not show slug input when editing existing collection", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Slug input is only rendered when isNew, so it shouldn't exist
const slugInput = screen.getByLabelText("Slug");
await expect.element(slugInput).not.toBeInTheDocument();
});
// ---- Supports checkboxes toggle correctly ----
it("toggles support checkboxes", async () => {
const collection = makeCollection({ supports: ["drafts"] });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// "Drafts" should be checked initially
const draftsCheckbox = screen.getByRole("checkbox", { name: DRAFTS_CHECKBOX_REGEX });
await expect.element(draftsCheckbox).toBeChecked();
// "Revisions" should not be checked
const revisionsCheckbox = screen.getByRole("checkbox", { name: REVISIONS_CHECKBOX_REGEX });
await expect.element(revisionsCheckbox).not.toBeChecked();
// Toggle revisions on
await revisionsCheckbox.click();
await expect.element(revisionsCheckbox).toBeChecked();
// Toggle drafts off
await draftsCheckbox.click();
await expect.element(draftsCheckbox).not.toBeChecked();
});
// ---- Save button disabled when no changes ----
it("save button is disabled when no changes have been made", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).toBeDisabled();
});
// ---- Save button enabled after changing a field ----
it("save button is enabled after changing label", async () => {
const collection = makeCollection({ label: "Posts" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const labelInput = screen.getByLabelText("Label (Plural)");
await labelInput.fill("Articles");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).toBeEnabled();
});
// ---- onSave called with correct input shape ----
it("calls onSave with correct input when creating new collection", async () => {
const onSave = vi.fn();
const screen = await render(<ContentTypeEditor {...defaultProps({ onSave })} isNew />);
await screen.getByLabelText("Label (Plural)").fill("Articles");
await screen.getByLabelText("Label (Singular)").fill("Article");
const createButton = screen.getByRole("button", { name: CREATE_CONTENT_TYPE_BUTTON_REGEX });
await createButton.click();
expect(onSave).toHaveBeenCalledWith({
slug: "articles",
label: "Articles",
labelSingular: "Article",
description: undefined,
urlPattern: undefined,
supports: ["drafts", "revisions"], // default
hasSeo: false,
});
});
it("calls onSave with correct input when editing existing collection", async () => {
const onSave = vi.fn();
const collection = makeCollection({ label: "Posts", supports: ["drafts"] });
const screen = await render(
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
);
await screen.getByLabelText("Label (Plural)").fill("Articles");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await saveButton.click();
expect(onSave).toHaveBeenCalledWith({
label: "Articles",
labelSingular: "Post",
description: "Blog posts",
urlPattern: undefined,
supports: ["drafts"],
hasSeo: false,
commentsEnabled: false,
commentsModeration: "first_time",
commentsClosedAfterDays: 90,
commentsAutoApproveUsers: true,
});
});
// ---- Field list displays existing fields with type and badges ----
it("displays custom fields with type and badges", async () => {
const fields: SchemaField[] = [
makeField({ slug: "title", label: "Title", type: "string", required: true, unique: true }),
makeField({
id: "field-2",
slug: "body",
label: "Body",
type: "portableText",
searchable: true,
}),
];
const collection = makeCollection({ fields });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Verify fields render by checking their edit buttons (unique aria-labels)
await expect.element(screen.getByRole("button", { name: EDIT_TITLE_RE })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: EDIT_BODY_RE })).toBeInTheDocument();
// Badges — use exact: true to avoid matching system field descriptions like "Unique identifier"
await expect.element(screen.getByText("Required", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("Unique", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("Searchable", { exact: true })).toBeInTheDocument();
});
// ---- System fields always shown ----
it("shows system fields section", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await expect.element(screen.getByText("System Fields")).toBeInTheDocument();
// System fields show descriptions — use those as unambiguous locators
await expect.element(screen.getByText("Unique identifier (ULID)")).toBeInTheDocument();
await expect.element(screen.getByText("URL-friendly identifier")).toBeInTheDocument();
await expect.element(screen.getByText("draft, published, or archived")).toBeInTheDocument();
await expect.element(screen.getByText("When the entry was created")).toBeInTheDocument();
await expect.element(screen.getByText("When the entry was last modified")).toBeInTheDocument();
await expect.element(screen.getByText("When the entry was published")).toBeInTheDocument();
});
// ---- Add field button opens FieldEditor dialog ----
it("opens FieldEditor dialog when Add Field is clicked", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Field editor should not be visible initially
const dialog = screen.getByTestId("field-editor-dialog");
await expect.element(dialog).not.toBeInTheDocument();
// Click Add Field
const addButton = screen.getByRole("button", { name: ADD_FIELD_BUTTON_REGEX });
await addButton.click();
// Dialog should now be visible
await expect.element(screen.getByTestId("field-editor-dialog")).toBeInTheDocument();
});
// ---- Delete field with confirm dialog calls onDeleteField ----
it("calls onDeleteField when delete is confirmed via dialog", async () => {
const onDeleteField = vi.fn();
const fields = [makeField({ slug: "title", label: "Title" })];
const collection = makeCollection({ fields });
const screen = await render(
<ContentTypeEditor {...defaultProps({ onDeleteField })} collection={collection} />,
);
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_PATTERN });
await deleteButton.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Delete" }).element().click();
expect(onDeleteField).toHaveBeenCalledWith("title");
});
it("does not call onDeleteField when delete dialog is cancelled", async () => {
const onDeleteField = vi.fn();
const fields = [makeField({ slug: "title", label: "Title" })];
const collection = makeCollection({ fields });
const screen = await render(
<ContentTypeEditor {...defaultProps({ onDeleteField })} collection={collection} />,
);
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_REGEX });
await deleteButton.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Cancel" }).element().click();
expect(onDeleteField).not.toHaveBeenCalled();
});
// ---- Code-source collections show disabled inputs and info banner ----
it("shows info banner and disables inputs for code-source collections", async () => {
const collection = makeCollection({ source: "code" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Info banner text
await expect.element(screen.getByText(CODE_DEFINED_MSG_REGEX)).toBeInTheDocument();
// Label inputs should be disabled
const labelInput = screen.getByLabelText("Label (Plural)");
await expect.element(labelInput).toBeDisabled();
const singularInput = screen.getByLabelText("Label (Singular)");
await expect.element(singularInput).toBeDisabled();
// Description uses InputArea — locate via placeholder
const descInput = screen.getByPlaceholder("A brief description of this content type");
await expect.element(descInput).toBeDisabled();
// Save button should not exist for code-source collections
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).not.toBeInTheDocument();
// Add Field button should not exist
const addFieldButton = screen.getByRole("button", { name: ADD_FIELD_BUTTON_REGEX });
await expect.element(addFieldButton).not.toBeInTheDocument();
});
it("hides edit and delete buttons on fields for code-source collections", async () => {
const fields = [makeField({ slug: "title", label: "Title" })];
const collection = makeCollection({ source: "code", fields });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const editButton = screen.getByRole("button", { name: EDIT_FIELD_BUTTON_REGEX });
await expect.element(editButton).not.toBeInTheDocument();
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_REGEX });
await expect.element(deleteButton).not.toBeInTheDocument();
});
// ---- Empty field list shows "No custom fields yet" ----
it("shows empty state when collection has no custom fields", async () => {
const collection = makeCollection({ fields: [] });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await expect.element(screen.getByText("No custom fields yet")).toBeInTheDocument();
await expect
.element(screen.getByText("Add fields to define the structure of your content"))
.toBeInTheDocument();
});
// ---- Fields section hidden for new collections ----
it("does not show fields section when creating new collection", async () => {
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
const fieldsHeading = screen.getByRole("heading", { name: "Fields" });
await expect.element(fieldsHeading).not.toBeInTheDocument();
});
// ---- isSaving shows saving state ----
it("shows 'Saving...' when isSaving is true", async () => {
const collection = makeCollection();
const screen = await render(
<ContentTypeEditor {...defaultProps()} collection={collection} isSaving />,
);
// Both the sticky-header SaveButton and the in-form "Save Changes"
// button render their saving label when isSaving is true; we render
// two save buttons by design (top-right sticky for sighted/pointer
// users + bottom-of-form for keyboard / screen-reader users following
// DOM order). Assert at least one matches.
await expect
.element(screen.getByRole("button", { name: SAVING_BUTTON_REGEX }).first())
.toBeInTheDocument();
});
// ---- Sticky-header save (issue #233) ----
it("renders a sticky-header save button when editing existing collection", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// The sticky save button uses the SaveButton component, which renders
// "Saved" when there are no unsaved changes. The bottom-of-form button
// renders "Save Changes". Both should be present so users have a
// visible save action regardless of where they are on the page.
// Use exact: true so "Save Changes" doesn't shadow the sticky "Saved"
// match (Playwright role-name matching is partial by default).
await expect
.element(screen.getByRole("button", { name: "Saved", exact: true }))
.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX }))
.toBeInTheDocument();
});
it("sticky-header save flips to enabled 'Save' when fields change", async () => {
const collection = makeCollection({ label: "Posts" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Initially clean -> "Saved" disabled
await expect.element(screen.getByRole("button", { name: "Saved", exact: true })).toBeDisabled();
// Make a change -> sticky button should now read "Save" and be enabled.
// exact:true so it doesn't also match "Save Changes".
await screen.getByLabelText("Label (Plural)").fill("Articles");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
});
it("sticky-header save submits the form (calls onSave)", async () => {
const onSave = vi.fn();
const collection = makeCollection({ label: "Posts" });
const screen = await render(
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
);
await screen.getByLabelText("Label (Plural)").fill("Articles");
await screen.getByRole("button", { name: "Save", exact: true }).click();
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ label: "Articles" }));
});
it("does not render sticky-header save for code-source collections", async () => {
const collection = makeCollection({ source: "code" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Neither sticky variant should appear (and the bottom Save Changes
// button is also hidden for code-source collections).
await expect
.element(screen.getByRole("button", { name: "Saved", exact: true }))
.not.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.not.toBeInTheDocument();
});
it("does not render sticky-header save when creating a new collection", async () => {
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
// On the new flow only the bottom 'Create Content Type' button is
// expected. No SaveButton ('Save'/'Saved') should render at the top.
await expect
.element(screen.getByRole("button", { name: "Saved", exact: true }))
.not.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.not.toBeInTheDocument();
});
// ---- URL Pattern field ----
it("shows URL Pattern input", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const input = screen.getByLabelText("URL Pattern");
await expect.element(input).toBeInTheDocument();
await expect.element(input).toHaveValue("");
});
it("populates URL Pattern from collection", async () => {
const collection = makeCollection({ urlPattern: "/blog/{slug}" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const input = screen.getByLabelText("URL Pattern");
await expect.element(input).toHaveValue("/blog/{slug}");
});
it("includes urlPattern in onSave when set", async () => {
const onSave = vi.fn();
const collection = makeCollection();
const screen = await render(
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
);
await screen.getByLabelText("URL Pattern").fill("/blog/{slug}");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await saveButton.click();
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: "/blog/{slug}" }));
});
it("shows validation error when pattern lacks {slug}", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await screen.getByLabelText("URL Pattern").fill("/blog/broken");
await expect.element(screen.getByText(URL_PATTERN_SLUG_RE)).toBeInTheDocument();
});
it("disables save button when pattern lacks {slug}", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await screen.getByLabelText("URL Pattern").fill("/blog/broken");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).toBeDisabled();
});
it("enables save button when pattern includes {slug}", async () => {
const collection = makeCollection();
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
await screen.getByLabelText("URL Pattern").fill("/blog/{slug}");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).toBeEnabled();
});
it("allows empty URL Pattern (field is optional)", async () => {
const onSave = vi.fn();
const collection = makeCollection({ label: "Posts" });
const screen = await render(
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
);
// Change label to enable save (urlPattern empty is fine)
await screen.getByLabelText("Label (Plural)").fill("Articles");
const saveButton = screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX });
await expect.element(saveButton).toBeEnabled();
await saveButton.click();
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: undefined }));
});
it("disables URL Pattern input for code-source collections", async () => {
const collection = makeCollection({ source: "code", urlPattern: "/blog/{slug}" });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
const input = screen.getByLabelText("URL Pattern");
await expect.element(input).toBeDisabled();
});
it("shows field count in header", async () => {
const fields = [
makeField({ slug: "title", label: "Title" }),
makeField({ id: "field-2", slug: "body", label: "Body" }),
];
const collection = makeCollection({ fields });
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
// Should show "6 system + 2 custom fields"
await expect.element(screen.getByText(SYSTEM_FIELDS_REGEX)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,225 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentTypeList } from "../../src/components/ContentTypeList";
import type { SchemaCollection, OrphanedTable } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NO_CONTENT_TYPES_REGEX = /No content types yet/;
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({
children,
to,
params: _params,
...props
}: {
children: React.ReactNode;
to?: string;
params?: Record<string, string>;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
};
});
function makeCollection(overrides: Partial<SchemaCollection> = {}): SchemaCollection {
return {
id: "col_01",
slug: "posts",
label: "Posts",
labelSingular: "Post",
supports: ["drafts", "revisions"],
source: "dashboard",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
};
}
function makeOrphan(overrides: Partial<OrphanedTable> = {}): OrphanedTable {
return {
slug: "legacy_posts",
tableName: "ec_legacy_posts",
rowCount: 42,
...overrides,
};
}
describe("ContentTypeList", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("rendering collections", () => {
it("displays collections with labels and slugs", async () => {
const collections = [
makeCollection({ id: "1", slug: "articles", label: "Articles" }),
makeCollection({ id: "2", slug: "landing_pages", label: "Landing Pages" }),
];
const screen = await render(<ContentTypeList collections={collections} />);
await expect.element(screen.getByText("Articles", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("Landing Pages", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("articles", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("landing_pages", { exact: true })).toBeInTheDocument();
});
it("shows 'Code' badge for code-source collections", async () => {
const collections = [makeCollection({ id: "1", source: "code" })];
const screen = await render(<ContentTypeList collections={collections} />);
await expect.element(screen.getByText("Code")).toBeInTheDocument();
});
it("shows 'Dashboard' badge for dashboard-source collections", async () => {
const collections = [makeCollection({ id: "1", source: "dashboard" })];
const screen = await render(<ContentTypeList collections={collections} />);
await expect.element(screen.getByText("Dashboard")).toBeInTheDocument();
});
it("shows feature badges from supports array", async () => {
const collections = [
makeCollection({
id: "1",
supports: ["drafts", "revisions", "preview", "search"],
}),
];
const screen = await render(<ContentTypeList collections={collections} />);
await expect.element(screen.getByText("drafts")).toBeInTheDocument();
await expect.element(screen.getByText("revisions")).toBeInTheDocument();
await expect.element(screen.getByText("preview")).toBeInTheDocument();
await expect.element(screen.getByText("search")).toBeInTheDocument();
});
});
describe("navigation", () => {
it("edit link navigates to /content-types/$slug", async () => {
const collections = [makeCollection({ id: "1", slug: "articles", label: "Articles" })];
const screen = await render(<ContentTypeList collections={collections} />);
const editLink = screen.getByRole("link", { name: "Edit Articles" });
await expect.element(editLink).toBeInTheDocument();
});
it("'New Content Type' link is present", async () => {
const screen = await render(<ContentTypeList collections={[]} />);
await expect.element(screen.getByText("New Content Type")).toBeInTheDocument();
});
});
describe("delete", () => {
it("delete button only shown for non-code-source collections", async () => {
const collections = [
makeCollection({ id: "1", slug: "from-code", label: "From Code", source: "code" }),
makeCollection({
id: "2",
slug: "from-dash",
label: "From Dashboard",
source: "dashboard",
}),
];
const screen = await render(<ContentTypeList collections={collections} />);
// Code-sourced should have no delete button
expect(screen.getByRole("button", { name: "Delete From Code" }).query()).toBeNull();
// Dashboard-sourced should have delete button
await expect
.element(screen.getByRole("button", { name: "Delete From Dashboard" }))
.toBeInTheDocument();
});
it("opens confirm dialog and calls onDelete after confirm", async () => {
const onDelete = vi.fn();
const collections = [
makeCollection({ id: "1", slug: "posts", label: "Posts", source: "dashboard" }),
];
const screen = await render(
<ContentTypeList collections={collections} onDelete={onDelete} />,
);
await screen.getByRole("button", { name: "Delete Posts" }).click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Delete" }).element().click();
expect(onDelete).toHaveBeenCalledWith("posts");
});
it("does not call onDelete when confirm dialog is cancelled", async () => {
const onDelete = vi.fn();
const collections = [
makeCollection({ id: "1", slug: "posts", label: "Posts", source: "dashboard" }),
];
const screen = await render(
<ContentTypeList collections={collections} onDelete={onDelete} />,
);
await screen.getByRole("button", { name: "Delete Posts" }).click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Cancel" }).element().click();
expect(onDelete).not.toHaveBeenCalled();
});
});
describe("orphaned tables", () => {
it("shows warning when orphanedTables has items", async () => {
const orphans = [makeOrphan({ slug: "old_content", rowCount: 15 })];
const screen = await render(<ContentTypeList collections={[]} orphanedTables={orphans} />);
await expect
.element(screen.getByText("Unregistered Content Tables Found"))
.toBeInTheDocument();
await expect.element(screen.getByText("old_content")).toBeInTheDocument();
await expect.element(screen.getByText("(15 items)")).toBeInTheDocument();
});
it("register button calls onRegisterOrphan", async () => {
const onRegisterOrphan = vi.fn();
const orphans = [makeOrphan({ slug: "legacy_data", rowCount: 5 })];
const screen = await render(
<ContentTypeList
collections={[]}
orphanedTables={orphans}
onRegisterOrphan={onRegisterOrphan}
/>,
);
await screen.getByRole("button", { name: "Register" }).click();
expect(onRegisterOrphan).toHaveBeenCalledWith("legacy_data");
});
it("does not show orphan warning when orphanedTables is empty", async () => {
const screen = await render(<ContentTypeList collections={[]} orphanedTables={[]} />);
expect(screen.getByText("Unregistered Content Tables Found").query()).toBeNull();
});
});
describe("empty state", () => {
it("shows 'No content types yet' when no collections", async () => {
const screen = await render(<ContentTypeList collections={[]} />);
await expect.element(screen.getByText(NO_CONTENT_TYPES_REGEX)).toBeInTheDocument();
});
});
describe("loading state", () => {
it("shows loading message when isLoading is true", async () => {
const screen = await render(<ContentTypeList collections={[]} isLoading />);
await expect.element(screen.getByText("Loading collections...")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { describe, it, expect } from "vitest";
import { EditorHeader } from "../../src/components/EditorHeader";
import { render } from "../utils/render";
describe("EditorHeader", () => {
it("renders title content", async () => {
const screen = await render(
<EditorHeader>
<h1>Hello title</h1>
</EditorHeader>,
);
await expect.element(screen.getByRole("heading", { name: "Hello title" })).toBeInTheDocument();
});
it("renders leading content next to the title", async () => {
const screen = await render(
<EditorHeader leading={<button type="button">Back</button>}>
<h1>Title</h1>
</EditorHeader>,
);
await expect.element(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
});
it("renders actions area for the primary save button", async () => {
const screen = await render(
<EditorHeader actions={<button type="submit">Save</button>}>
<h1>Title</h1>
</EditorHeader>,
);
await expect.element(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
it("applies sticky utility classes by default", async () => {
const screen = await render(
<EditorHeader actions={<button type="submit">Save</button>}>
<h1>Title</h1>
</EditorHeader>,
);
// Locate the wrapper via the data attribute on the root.
const wrapper = screen.getByText("Title").element().closest("[data-editor-header]");
expect(wrapper).not.toBeNull();
expect(wrapper?.classList.contains("sticky")).toBe(true);
expect(wrapper?.classList.contains("top-0")).toBe(true);
});
it("omits sticky classes when sticky=false", async () => {
const screen = await render(
<EditorHeader sticky={false} actions={<button type="submit">Save</button>}>
<h1>Title</h1>
</EditorHeader>,
);
const wrapper = screen.getByText("Title").element().closest("[data-editor-header]");
expect(wrapper).not.toBeNull();
expect(wrapper?.classList.contains("sticky")).toBe(false);
});
it("omits the actions area when no actions prop is provided", async () => {
const screen = await render(
<EditorHeader>
<h1>Just a title</h1>
</EditorHeader>,
);
// The actions container has flex + gap utility classes; assert it
// isn't present in the header subtree. Looking at the underlying DOM
// avoids relying on global queries that may be affected by leftover
// nodes from earlier tests in the shared browser session.
const wrapper = screen.getByText("Just a title").element().closest("[data-editor-header]");
expect(wrapper).not.toBeNull();
// Only one direct child div (the title group) — no actions div.
const directChildren = wrapper!.children;
expect(directChildren.length).toBe(1);
});
});

View File

@@ -0,0 +1,347 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FieldEditor } from "../../src/components/FieldEditor";
import type { SchemaField } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FIELD_TYPE_REGEXES = [
/Short Text/,
/Long Text/,
/Number Decimal number/,
/Integer Whole number/,
/Boolean True\/false toggle/,
/Date & Time/,
/^Select Single choice/,
/Multi Select/,
/Rich Text/,
/Image/,
/^File File from/,
/Reference/,
/JSON/,
/Slug URL-friendly/,
];
const SHORT_TEXT_REGEX = /Short Text/;
const LONG_TEXT_REGEX = /Long Text/;
const BOOLEAN_REGEX = /Boolean/;
const RICH_TEXT_REGEX = /Rich Text Rich text editor/;
function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
return {
id: "field_01",
collectionId: "col_01",
slug: "title",
label: "Title",
type: "string",
columnType: "TEXT",
required: true,
unique: false,
searchable: true,
sortOrder: 0,
createdAt: new Date().toISOString(),
...overrides,
};
}
// The kumo Dialog renders a `data-base-ui-inert` overlay that blocks pointer
// events inside the dialog in Playwright's actionability checks. Assertions
// (toBeInTheDocument, toHaveValue, etc.) work fine; only click() is blocked.
//
// Strategy:
// - Type selection step: assert type buttons exist (no clicking needed)
// - Config step: use edit mode (pass field prop) to go directly to config
// - onSave/callbacks: use edit mode fields to test form submission
describe("FieldEditor", () => {
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
onSave: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("type selection step", () => {
it("shows type selection grid when creating new field", async () => {
const screen = await render(<FieldEditor {...defaultProps} />);
await expect.element(screen.getByText("Add Field")).toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: SHORT_TEXT_REGEX }))
.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: LONG_TEXT_REGEX }))
.toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: BOOLEAN_REGEX })).toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: RICH_TEXT_REGEX }))
.toBeInTheDocument();
});
it("shows all 14 field types as buttons", async () => {
const screen = await render(<FieldEditor {...defaultProps} />);
// Each type renders as a button with label and description
for (const name of FIELD_TYPE_REGEXES) {
await expect.element(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
it("does not show config form on initial render", async () => {
const screen = await render(<FieldEditor {...defaultProps} />);
// Label and Slug inputs should NOT be present in type selection step
expect(screen.getByLabelText("Label").query()).toBeNull();
expect(screen.getByLabelText("Slug").query()).toBeNull();
});
});
describe("config step (string field)", () => {
// Use a minimal string field to go directly to config step
const stringField = makeField({
slug: "",
label: "",
type: "string",
required: false,
unique: false,
searchable: false,
});
it("shows Configure Field title for string type", async () => {
const screen = await render(
<FieldEditor {...defaultProps} field={makeField({ type: "string" })} />,
);
await expect.element(screen.getByText("Edit Field")).toBeInTheDocument();
});
it("shows label and slug inputs", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
});
it("shows searchable checkbox for string type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
});
it("shows min/max length validation for string type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
await expect.element(screen.getByText("Validation")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Min Length")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Max Length")).toBeInTheDocument();
});
it("shows pattern input for string type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
await expect.element(screen.getByLabelText("Pattern (Regex)")).toBeInTheDocument();
});
it("shows required and unique checkboxes", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
await expect.element(screen.getByText("Required")).toBeInTheDocument();
await expect.element(screen.getByText("Unique")).toBeInTheDocument();
});
});
describe("config step (number field)", () => {
const numberField = makeField({
slug: "",
label: "",
type: "number",
required: false,
unique: false,
searchable: false,
});
it("shows min/max value for number type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
await expect.element(screen.getByLabelText("Min Value")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Max Value")).toBeInTheDocument();
});
it("does not show searchable for number type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
expect(screen.getByText("Searchable").query()).toBeNull();
});
it("does not show pattern for number type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
expect(screen.getByLabelText("Pattern (Regex)").query()).toBeNull();
});
it("does not show min/max length for number type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
expect(screen.getByLabelText("Min Length").query()).toBeNull();
expect(screen.getByLabelText("Max Length").query()).toBeNull();
});
});
describe("config step (text field)", () => {
const textField = makeField({
slug: "",
label: "",
type: "text",
required: false,
unique: false,
searchable: false,
});
it("shows min/max length but no pattern for text type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={textField} />);
await expect.element(screen.getByLabelText("Min Length")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Max Length")).toBeInTheDocument();
expect(screen.getByLabelText("Pattern (Regex)").query()).toBeNull();
});
it("shows searchable checkbox for text type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={textField} />);
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
});
});
describe("config step (select field)", () => {
const selectField = makeField({
slug: "",
label: "",
type: "select",
required: false,
unique: false,
searchable: false,
});
it("shows options textarea for select type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={selectField} />);
await expect.element(screen.getByText("Options (one per line)")).toBeInTheDocument();
// Textarea should have the placeholder
await expect.element(screen.getByPlaceholder("Option 1")).toBeInTheDocument();
});
});
describe("config step (multiSelect field)", () => {
const multiSelectField = makeField({
slug: "",
label: "",
type: "multiSelect",
required: false,
unique: false,
searchable: false,
});
it("shows options textarea for multi-select type", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={multiSelectField} />);
await expect.element(screen.getByText("Options (one per line)")).toBeInTheDocument();
await expect.element(screen.getByPlaceholder("Option 1")).toBeInTheDocument();
});
});
describe("edit mode", () => {
const existingField = makeField({
validation: { maxLength: 200 },
});
it("skips type selection and shows config directly", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect.element(screen.getByText("Edit Field")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Label")).toHaveValue("Title");
});
it("disables slug input in edit mode", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect.element(screen.getByLabelText("Slug")).toBeDisabled();
});
it("shows hint about slug immutability", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect
.element(screen.getByText("Field slugs cannot be changed after creation"))
.toBeInTheDocument();
});
it("does not show Change button in edit mode", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
expect(screen.getByRole("button", { name: "Change" }).query()).toBeNull();
});
it("shows Update Field button instead of Add Field", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect
.element(screen.getByRole("button", { name: "Update Field" }))
.toBeInTheDocument();
});
it("pre-populates validation values", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect.element(screen.getByLabelText("Max Length")).toHaveValue(200);
});
it("pre-populates slug value", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect.element(screen.getByLabelText("Slug")).toHaveValue("title");
});
it("pre-populates required checkbox", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
// The Required checkbox text should be present (the field has required: true)
await expect.element(screen.getByText("Required")).toBeInTheDocument();
});
it("does not auto-generate slug when editing label in edit mode", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await screen.getByLabelText("Label").fill("New Label");
// Slug should remain "title", not change to "new_label"
await expect.element(screen.getByLabelText("Slug")).toHaveValue("title");
});
it("shows type indicator with field type info", async () => {
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
await expect.element(screen.getByText("Short Text")).toBeInTheDocument();
await expect.element(screen.getByText("Single line text input")).toBeInTheDocument();
});
});
describe("saving state", () => {
it("shows Saving... when isSaving is true", async () => {
const field = makeField();
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
await expect.element(screen.getByText("Saving...")).toBeInTheDocument();
});
it("disables cancel button when saving", async () => {
const field = makeField();
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("disables update button when saving", async () => {
const field = makeField();
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
});
});
describe("button state", () => {
it("disables save button when label is empty", async () => {
const field = makeField({ slug: "test", label: "" });
const screen = await render(<FieldEditor {...defaultProps} field={field} />);
await expect.element(screen.getByRole("button", { name: "Update Field" })).toBeDisabled();
});
it("enables save button when label and slug are filled", async () => {
const field = makeField({ slug: "test", label: "Test" });
const screen = await render(<FieldEditor {...defaultProps} field={field} />);
await expect.element(screen.getByRole("button", { name: "Update Field" })).toBeEnabled();
});
});
describe("dialog closed", () => {
it("renders nothing visible when open is false", async () => {
const screen = await render(<FieldEditor {...defaultProps} open={false} />);
expect(screen.getByText("Add Field").query()).toBeNull();
});
});
});

View File

@@ -0,0 +1,106 @@
import { Sidebar } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ThemeProvider } from "../../src/components/ThemeProvider";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
// Mock API
vi.mock("../../src/lib/api/client", async () => {
const actual = await vi.importActual("../../src/lib/api/client");
return {
...actual,
apiFetch: vi.fn().mockImplementation((url: string) => {
if (url.includes("/auth/me")) {
return Promise.resolve(
new Response(
JSON.stringify({
data: {
id: "1",
name: "Matt Kane",
email: "matt@test.com",
role: 50,
},
}),
{ status: 200 },
),
);
}
return Promise.resolve(new Response(JSON.stringify({ data: {} }), { status: 200 }));
}),
};
});
// Import after mocks
const { Header } = await import("../../src/components/Header");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const THEME_BUTTON_REGEX = /Theme:/;
function TestWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider defaultTheme="light">
<Sidebar.Provider defaultOpen>{children}</Sidebar.Provider>
</ThemeProvider>
</QueryClientProvider>
);
}
describe("Header", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-mode");
});
it("theme toggle button is present", async () => {
const screen = await render(
<TestWrapper>
<Header />
</TestWrapper>,
);
// ThemeToggle renders a button with title containing "Theme:"
const themeButton = screen.getByTitle(THEME_BUTTON_REGEX);
await expect.element(themeButton).toBeInTheDocument();
});
it("displays user name when loaded", async () => {
const screen = await render(
<TestWrapper>
<Header />
</TestWrapper>,
);
// User data loads async via react-query
await expect.element(screen.getByText("Matt Kane")).toBeInTheDocument();
});
it("View Site link is present", async () => {
const screen = await render(
<TestWrapper>
<Header />
</TestWrapper>,
);
await expect.element(screen.getByText("View Site")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,143 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
// Mock API — keep a reference so tests can override fetchAuthMode
const mockFetchAuthMode = vi.fn().mockResolvedValue({
authMode: "passkey",
});
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchAuthMode: (...args: unknown[]) => mockFetchAuthMode(...args),
apiFetch: vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })),
};
});
// Mock WebAuthn APIs so PasskeyLogin doesn't bail out
Object.defineProperty(window, "PublicKeyCredential", {
value: function PublicKeyCredential() {},
writable: true,
});
// Import after mocks
const { LoginPage } = await import("../../src/components/LoginPage");
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("LoginPage", () => {
beforeEach(() => {
// Clean URL params
window.history.replaceState({}, "", window.location.pathname);
});
it("shows passkey login button when authMode is passkey", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
});
it("shows 'Sign in with email link' button", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
await expect.element(screen.getByText("Sign in with email link")).toBeInTheDocument();
});
it("clicking email link button switches to magic link form", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
const emailButton = screen.getByText("Sign in with email link");
await emailButton.click();
// Heading should change
await expect.element(screen.getByText("Sign in with email")).toBeInTheDocument();
});
it("magic link form has email input and submit button", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
// Switch to magic link
await screen.getByText("Sign in with email link").click();
// Check for email input (by placeholder)
await expect.element(screen.getByPlaceholder("you@example.com")).toBeInTheDocument();
// Check for submit button
await expect.element(screen.getByText("Send magic link")).toBeInTheDocument();
});
it("'Back to login' from magic link returns to passkey view", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
// Switch to magic link
await screen.getByText("Sign in with email link").click();
await expect.element(screen.getByText("Sign in with email")).toBeInTheDocument();
// Click back
await screen.getByText("Back to login").click();
// Should see passkey button again
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
});
it("hides sign up link when signup is not enabled", async () => {
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
// Wait for manifest to load (passkey button appears)
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
// Sign up link should NOT be present
expect(screen.getByText("Sign up").query()).toBeNull();
});
it("shows sign up link when signup is enabled", async () => {
mockFetchAuthMode.mockResolvedValueOnce({
authMode: "passkey",
signupEnabled: true,
});
const screen = await render(
<QueryWrapper>
<LoginPage />
</QueryWrapper>,
);
await expect.element(screen.getByText("Sign up")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,306 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type {
MarketplaceSearchResult,
MarketplacePluginSummary,
} from "../../src/lib/api/marketplace";
import { render } from "../utils/render.tsx";
// Mock router
const mockNavigate = vi.fn();
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, params, ...props }: any) => {
const href = params?.pluginId ? to.replace("$pluginId", params.pluginId) : to;
return (
<a href={href} {...props}>
{children}
</a>
);
},
useNavigate: () => mockNavigate,
};
});
const mockSearchMarketplace = vi.fn<() => Promise<MarketplaceSearchResult>>();
vi.mock("../../src/lib/api/marketplace", async () => {
const actual = await vi.importActual("../../src/lib/api/marketplace");
return {
...actual,
searchMarketplace: (...args: unknown[]) => mockSearchMarketplace(...(args as [])),
};
});
// Import after mocks
const { MarketplaceBrowse } = await import("../../src/components/MarketplaceBrowse");
function makePlugin(overrides: Partial<MarketplacePluginSummary> = {}): MarketplacePluginSummary {
return {
id: "test-plugin",
name: "Test Plugin",
description: "A test plugin for testing",
author: { name: "Test Author", verified: false },
capabilities: ["read:content"],
installCount: 1234,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-02-01T00:00:00Z",
latestVersion: {
version: "1.0.0",
audit: { verdict: "pass", riskScore: 10 },
},
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("MarketplaceBrowse", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchMarketplace.mockResolvedValue({
items: [
makePlugin({
id: "seo-helper",
name: "SEO Helper",
description: "Improve your SEO",
author: { name: "Acme Inc", verified: true },
installCount: 5200,
capabilities: ["read:content", "write:content"],
latestVersion: {
version: "1.2.3",
audit: { verdict: "pass", riskScore: 10 },
},
}),
makePlugin({
id: "analytics",
name: "Analytics",
description: "Track page views",
author: { name: "DataCorp", verified: false },
installCount: 890,
capabilities: ["network:fetch"],
latestVersion: {
version: "2.0.0",
audit: { verdict: "warn", riskScore: 45 },
},
}),
],
});
});
it("renders marketplace header", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("Marketplace")).toBeInTheDocument();
await expect
.element(screen.getByText("Browse and install plugins to extend your site."))
.toBeInTheDocument();
});
it("displays plugin cards with names and authors", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
await expect.element(screen.getByText("Analytics")).toBeInTheDocument();
await expect.element(screen.getByText("DataCorp")).toBeInTheDocument();
});
it("shows plugin descriptions", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("Improve your SEO")).toBeInTheDocument();
await expect.element(screen.getByText("Track page views")).toBeInTheDocument();
});
it("formats install counts (K for thousands)", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
// 5200 → 5.2k
await expect.element(screen.getByText("5.2k")).toBeInTheDocument();
// 890 → 890
await expect.element(screen.getByText("890")).toBeInTheDocument();
});
it("shows permission count", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
// SEO Helper has 2 capabilities
await expect.element(screen.getByText("2 permissions")).toBeInTheDocument();
// Analytics has 1 capability
await expect.element(screen.getByText("1 permission")).toBeInTheDocument();
});
it("shows audit badges", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
// SEO Helper has "pass", Analytics has "warn"
await expect.element(screen.getByText("Pass")).toBeInTheDocument();
await expect.element(screen.getByText("Warn")).toBeInTheDocument();
});
it("shows 'Installed' badge for installed plugins", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse installedPluginIds={new Set(["seo-helper"])} />
</Wrapper>,
);
await expect.element(screen.getByText("Installed")).toBeInTheDocument();
});
it("shows empty state when no results", async () => {
mockSearchMarketplace.mockResolvedValue({ items: [] });
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("No plugins found")).toBeInTheDocument();
});
it("shows error state with retry button", async () => {
mockSearchMarketplace.mockRejectedValue(new Error("Network timeout"));
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("Unable to reach marketplace")).toBeInTheDocument();
await expect.element(screen.getByText("Network timeout")).toBeInTheDocument();
await expect.element(screen.getByText("Retry")).toBeInTheDocument();
});
it("has search input", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
const searchInput = screen.getByPlaceholder("Search plugins...");
await expect.element(searchInput).toBeInTheDocument();
});
it("has sort dropdown with options", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
const sortSelect = screen.getByRole("combobox", { name: "Sort plugins" });
await expect.element(sortSelect).toBeInTheDocument();
});
it("plugin cards link to detail page", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
// Wait for cards to render
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
// The Link wraps the card, creating an <a> with the plugin detail path
const links = screen.getByRole("link").all();
const seoLink = links.find((l) => l.element().getAttribute("href")?.includes("seo-helper"));
expect(seoLink).toBeDefined();
});
it("shows plugin avatar when no icon URL", async () => {
mockSearchMarketplace.mockResolvedValue({
items: [makePlugin({ id: "no-icon", name: "Zeta Plugin", iconUrl: undefined })],
});
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
// The avatar shows the first letter — use exact match to avoid matching "Zeta Plugin"
await expect.element(screen.getByText("Z", { exact: true })).toBeInTheDocument();
});
it("shows version numbers on cards", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("v1.2.3")).toBeInTheDocument();
await expect.element(screen.getByText("v2.0.0")).toBeInTheDocument();
});
it("has capability filter dropdown", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
const capabilitySelect = screen.getByRole("combobox", { name: "Filter by capability" });
await expect.element(capabilitySelect).toBeInTheDocument();
// Default option
await expect.element(screen.getByText("All capabilities")).toBeInTheDocument();
});
it("installed badge navigates to plugin manager on click", async () => {
const screen = await render(
<Wrapper>
<MarketplaceBrowse installedPluginIds={new Set(["seo-helper"])} />
</Wrapper>,
);
const badge = screen.getByText("Installed");
await expect.element(badge).toBeInTheDocument();
await badge.click();
expect(mockNavigate).toHaveBeenCalledWith({ to: "/plugins-manager" });
});
it("shows 'Load more' button when there are more pages", async () => {
mockSearchMarketplace.mockResolvedValue({
items: [makePlugin({ id: "plugin-1", name: "Plugin One" })],
nextCursor: "cursor-abc",
});
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("Load more")).toBeInTheDocument();
});
it("does not show 'Load more' when there are no more pages", async () => {
mockSearchMarketplace.mockResolvedValue({
items: [makePlugin({ id: "plugin-1", name: "Plugin One" })],
});
const screen = await render(
<Wrapper>
<MarketplaceBrowse />
</Wrapper>,
);
await expect.element(screen.getByText("Plugin One")).toBeInTheDocument();
expect(screen.getByText("Load more").query()).toBeNull();
});
});

View File

@@ -0,0 +1,418 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { MarketplacePluginDetail as PluginDetailType } from "../../src/lib/api/marketplace";
import { render } from "../utils/render.tsx";
const INSTALL_RE = /Install/;
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
const mockFetchMarketplacePlugin = vi.fn<() => Promise<PluginDetailType>>();
const mockInstallMarketplacePlugin = vi.fn<() => Promise<void>>();
vi.mock("../../src/lib/api/marketplace", async () => {
const actual = await vi.importActual("../../src/lib/api/marketplace");
return {
...actual,
fetchMarketplacePlugin: (...args: unknown[]) => mockFetchMarketplacePlugin(...(args as [])),
installMarketplacePlugin: (...args: unknown[]) => mockInstallMarketplacePlugin(...(args as [])),
};
});
// Import after mocks
const { MarketplacePluginDetail } = await import("../../src/components/MarketplacePluginDetail");
function makePluginDetail(overrides: Partial<PluginDetailType> = {}): PluginDetailType {
return {
id: "seo-helper",
name: "SEO Helper",
description: "Improve your SEO with automatic meta tags",
author: { name: "Acme Inc", verified: true },
capabilities: ["read:content", "write:content"],
keywords: ["seo", "meta", "optimization"],
installCount: 5200,
license: "MIT",
repositoryUrl: "https://github.com/acme/seo-helper",
homepageUrl: "https://seo-helper.example.com",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-02-01T00:00:00Z",
latestVersion: {
version: "2.1.0",
minEmDashVersion: "0.8.0",
bundleSize: 15360,
readme: "# SEO Helper\n\nThis plugin helps with SEO.",
audit: { verdict: "pass", riskScore: 5 },
publishedAt: "2025-02-01T00:00:00Z",
},
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("MarketplacePluginDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail());
mockInstallMarketplacePlugin.mockResolvedValue(undefined);
});
it("displays plugin name and description", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// Name appears in header h1 and also in rendered README h1 — use first()
await expect
.element(screen.getByRole("heading", { name: "SEO Helper" }).first())
.toBeInTheDocument();
await expect
.element(screen.getByText("Improve your SEO with automatic meta tags"))
.toBeInTheDocument();
});
it("shows author name with verified badge", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
});
it("shows version number", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// Wait for data to load — version appears in both header and sidebar
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
await expect.element(screen.getByText("v2.1.0").first()).toBeInTheDocument();
});
it("displays install count", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("5,200 installs")).toBeInTheDocument();
});
it("shows audit badge", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// Wait for data to load, then check audit badge (appears in stats bar and sidebar)
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
await expect.element(screen.getByText("Pass").first()).toBeInTheDocument();
});
it("shows license", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("MIT")).toBeInTheDocument();
});
it("shows source and website links", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Source")).toBeInTheDocument();
await expect.element(screen.getByText("Website")).toBeInTheDocument();
});
it("renders README content", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// The markdown renderer should convert "# SEO Helper" and the paragraph
await expect.element(screen.getByText("This plugin helps with SEO.")).toBeInTheDocument();
});
it("shows permissions sidebar", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Permissions")).toBeInTheDocument();
await expect.element(screen.getByText("Read your content")).toBeInTheDocument();
await expect
.element(screen.getByText("Create, update, and delete content"))
.toBeInTheDocument();
});
it("shows keywords", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Keywords")).toBeInTheDocument();
// "seo" appears in multiple places (name, description, keyword) — use exact match
await expect.element(screen.getByText("seo", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("meta", { exact: true })).toBeInTheDocument();
await expect.element(screen.getByText("optimization")).toBeInTheDocument();
});
it("shows audit summary with risk score", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Security Audit")).toBeInTheDocument();
await expect.element(screen.getByText("Risk score: 5/100")).toBeInTheDocument();
});
it("shows version info with min emdash version", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Version")).toBeInTheDocument();
await expect.element(screen.getByText("Requires EmDash 0.8.0")).toBeInTheDocument();
});
it("shows bundle size", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// 15360 bytes = 15.0 KB
await expect.element(screen.getByText("15.0 KB")).toBeInTheDocument();
});
it("shows Install button for non-installed plugin", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByRole("button", { name: INSTALL_RE })).toBeInTheDocument();
});
it("shows Installed badge for installed plugin", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail
pluginId="seo-helper"
installedPluginIds={new Set(["seo-helper"])}
/>
</Wrapper>,
);
await expect.element(screen.getByText("Installed")).toBeInTheDocument();
});
it("opens consent dialog on Install click", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// Wait for data to load, then click Install button
const installBtn = screen.getByRole("button", { name: INSTALL_RE });
await expect.element(installBtn).toBeInTheDocument();
await installBtn.click();
// Consent dialog should appear
await expect.element(screen.getByText("Plugin Permissions")).toBeInTheDocument();
await expect
.element(screen.getByText("SEO Helper requires the following permissions:"))
.toBeInTheDocument();
});
it("shows error state when plugin fails to load", async () => {
mockFetchMarketplacePlugin.mockRejectedValue(new Error("Plugin not found"));
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="nonexistent" />
</Wrapper>,
);
await expect.element(screen.getByText("Failed to load plugin")).toBeInTheDocument();
await expect.element(screen.getByText("Plugin not found")).toBeInTheDocument();
// "Back to marketplace" appears multiple times (header + error) — just check one
const backLinks = screen.getByText("Back to marketplace").all();
expect(backLinks.length).toBeGreaterThanOrEqual(1);
});
it("shows 'No detailed description' when no README", async () => {
mockFetchMarketplacePlugin.mockResolvedValue(
makePluginDetail({
latestVersion: {
version: "1.0.0",
bundleSize: 0,
publishedAt: "2025-01-01T00:00:00Z",
},
}),
);
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect
.element(screen.getByText("No detailed description available."))
.toBeInTheDocument();
});
it("shows 'no special permissions' when capabilities empty", async () => {
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail({ capabilities: [] }));
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect
.element(screen.getByText("This plugin requires no special permissions."))
.toBeInTheDocument();
});
it("renders screenshots when present", async () => {
mockFetchMarketplacePlugin.mockResolvedValue(
makePluginDetail({
latestVersion: {
version: "2.1.0",
bundleSize: 15360,
screenshotUrls: [
"https://example.com/screenshot1.png",
"https://example.com/screenshot2.png",
],
audit: { verdict: "pass", riskScore: 5 },
publishedAt: "2025-02-01T00:00:00Z",
},
}),
);
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Screenshots")).toBeInTheDocument();
// Two screenshot images
const imgs = screen.getByRole("img").all();
const screenshotImgs = imgs.filter((img) =>
img.element().getAttribute("alt")?.startsWith("Screenshot"),
);
expect(screenshotImgs.length).toBe(2);
});
it("has back link to marketplace", async () => {
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
await expect.element(screen.getByText("Back to marketplace")).toBeInTheDocument();
});
it("shows plugin avatar when no icon URL", async () => {
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail({ iconUrl: undefined }));
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// First letter of "SEO Helper" is "S" — use exact match
await expect.element(screen.getByText("S", { exact: true })).toBeInTheDocument();
});
describe("XSS prevention in README rendering", () => {
async function renderWithReadme(readme: string) {
mockFetchMarketplacePlugin.mockResolvedValue(
makePluginDetail({
latestVersion: {
version: "1.0.0",
bundleSize: 0,
readme,
audit: { verdict: "pass", riskScore: 0 },
publishedAt: "2025-01-01T00:00:00Z",
},
}),
);
const screen = await render(
<Wrapper>
<MarketplacePluginDetail pluginId="seo-helper" />
</Wrapper>,
);
// Wait for data to load
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
return screen;
}
it("strips img tags with onerror handlers from link text", async () => {
const screen = await renderWithReadme(
"[<img src=x onerror=alert(document.cookie)>](https://example.com)",
);
// The prose div contains the rendered README markdown
const prose = screen.container.querySelector(".prose")!;
// No img element should exist in the rendered output
expect(prose.querySelectorAll("img[onerror]").length).toBe(0);
// The onerror text should be escaped (visible as text), not as an attribute
expect(prose.querySelectorAll("[onerror]").length).toBe(0);
});
it("strips attribute breakout via quotes in link text", async () => {
const screen = await renderWithReadme('[a" onmouseover="alert(1)](https://example.com)');
// The prose div contains the rendered README markdown
const prose = screen.container.querySelector(".prose")!;
// No element should have an onmouseover attribute
expect(prose.querySelectorAll("[onmouseover]").length).toBe(0);
});
it("strips raw script tags from README", async () => {
const screen = await renderWithReadme("Hello\n\n<script>alert('xss')</script>\n\nWorld");
expect(screen.container.querySelectorAll("script").length).toBe(0);
expect(screen.container.innerHTML).not.toContain("<script");
});
it("strips event handlers from raw HTML in README", async () => {
const screen = await renderWithReadme('<div onload="alert(1)">test</div>');
expect(screen.container.innerHTML).not.toContain("onload");
});
it("renders safe markdown content correctly", async () => {
const screen = await renderWithReadme(
"# Title\n\nA paragraph with **bold** and [a link](https://example.com).",
);
// The link text should be rendered as plain text within an anchor
await expect.element(screen.getByText("a link")).toBeInTheDocument();
const link = screen.container.querySelector('a[href="https://example.com"]');
expect(link).not.toBeNull();
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noopener noreferrer");
});
});
});

View File

@@ -0,0 +1,260 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MediaDetailPanel } from "../../src/components/MediaDetailPanel";
import type { MediaItem } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
updateMedia: vi.fn().mockResolvedValue({}),
deleteMedia: vi.fn().mockResolvedValue({}),
};
});
// Import the mocked functions for assertions
import { updateMedia, deleteMedia } from "../../src/lib/api";
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
function renderPanel(props: Partial<React.ComponentProps<typeof MediaDetailPanel>> = {}) {
const defaultProps = {
item: null as MediaItem | null,
onClose: vi.fn(),
onDeleted: vi.fn(),
...props,
};
return render(
<QueryWrapper>
<MediaDetailPanel {...defaultProps} />
</QueryWrapper>,
);
}
function makeImageItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "media-1",
filename: "photo.jpg",
mimeType: "image/jpeg",
url: "https://example.com/photo.jpg",
size: 204800,
width: 1920,
height: 1080,
alt: "A nice photo",
caption: "Photo caption",
createdAt: "2025-01-15T10:30:00Z",
...overrides,
};
}
function makePdfItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "media-2",
filename: "document.pdf",
mimeType: "application/pdf",
url: "https://example.com/document.pdf",
size: 1048576,
createdAt: "2025-01-15T10:30:00Z",
...overrides,
};
}
describe("MediaDetailPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders nothing when item is null", async () => {
const screen = await renderPanel({ item: null });
// The component returns null, so "Media Details" heading shouldn't exist
await expect
.element(screen.getByText("Media Details"), { timeout: 100 })
.not.toBeInTheDocument();
});
it("displays filename and file size", async () => {
const item = makeImageItem({ size: 204800 });
const screen = await renderPanel({ item });
// Filename is in a disabled input
const filenameInput = screen.getByLabelText("Filename");
await expect.element(filenameInput).toHaveValue("photo.jpg");
// 204800 bytes = 200 KB
await expect.element(screen.getByText("200 KB")).toBeInTheDocument();
});
it("displays dimensions for images", async () => {
const item = makeImageItem({ width: 1920, height: 1080 });
const screen = await renderPanel({ item });
await expect.element(screen.getByText("1920 × 1080")).toBeInTheDocument();
});
it("shows image preview for image mimeTypes", async () => {
const item = makeImageItem();
const screen = await renderPanel({ item });
const img = screen.getByAltText("A nice photo");
await expect.element(img).toBeInTheDocument();
await expect.element(img).toHaveAttribute("src", item.url);
});
it("does not show image preview for non-image mimeTypes", async () => {
const item = makePdfItem();
const screen = await renderPanel({ item });
// Should show the mime type text instead of img
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
});
it("alt text input is editable", async () => {
const item = makeImageItem({ alt: "Initial alt" });
const screen = await renderPanel({ item });
const altInput = screen.getByLabelText("Alt Text");
await expect.element(altInput).toBeInTheDocument();
await altInput.fill("New alt text");
await expect.element(altInput).toHaveValue("New alt text");
});
it("shows caption textarea only for images", async () => {
const imageItem = makeImageItem();
const screen = await renderPanel({ item: imageItem });
// Caption textarea should exist for images - find by placeholder
const captionArea = screen.getByPlaceholder("Optional caption for display");
await expect.element(captionArea).toBeInTheDocument();
await expect.element(captionArea).toHaveValue("Photo caption");
});
it("hides caption textarea for non-images", async () => {
const pdfItem = makePdfItem();
const screen = await renderPanel({ item: pdfItem });
await expect
.element(screen.getByPlaceholder("Optional caption for display"), { timeout: 100 })
.not.toBeInTheDocument();
});
it("hides caption textarea for non-images", async () => {
const pdfItem = makePdfItem();
const screen = await renderPanel({ item: pdfItem });
await expect
.element(screen.getByLabelText("Caption"), { timeout: 100 })
.not.toBeInTheDocument();
});
it("filename input is disabled", async () => {
const item = makeImageItem();
const screen = await renderPanel({ item });
const filenameInput = screen.getByLabelText("Filename");
await expect.element(filenameInput).toBeDisabled();
});
it("save button is disabled when no changes", async () => {
const item = makeImageItem();
const screen = await renderPanel({ item });
const saveBtn = screen.getByRole("button", { name: "Save" });
await expect.element(saveBtn).toBeDisabled();
});
it("save button is enabled after changing alt text", async () => {
const item = makeImageItem({ alt: "Original" });
const screen = await renderPanel({ item });
const altInput = screen.getByLabelText("Alt Text");
await altInput.fill("Changed alt text");
const saveBtn = screen.getByRole("button", { name: "Save" });
await expect.element(saveBtn).toBeEnabled();
});
it("save calls updateMedia with correct payload", async () => {
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
const screen = await renderPanel({ item });
const altInput = screen.getByLabelText("Alt Text");
await altInput.fill("New alt");
const saveBtn = screen.getByRole("button", { name: "Save" });
await saveBtn.click();
expect(updateMedia).toHaveBeenCalledWith("media-1", {
alt: "New alt",
caption: "Old caption",
});
});
it("delete with confirm calls deleteMedia and onClose + onDeleted", async () => {
const onClose = vi.fn();
const onDeleted = vi.fn();
const item = makeImageItem();
const screen = await renderPanel({ item, onClose, onDeleted });
const deleteBtn = screen.getByRole("button", { name: "Delete" });
await deleteBtn.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
const allDeleteBtns = screen.getByRole("button", { name: "Delete" }).all();
allDeleteBtns.at(-1)!.element().click();
// Wait for mutation to complete
await vi.waitFor(() => {
expect(deleteMedia).toHaveBeenCalledWith("media-1");
expect(onClose).toHaveBeenCalled();
expect(onDeleted).toHaveBeenCalled();
});
});
it("delete cancelled does not call deleteMedia", async () => {
const item = makeImageItem();
const screen = await renderPanel({ item });
const deleteBtn = screen.getByRole("button", { name: "Delete" });
await deleteBtn.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Cancel" }).element().click();
expect(deleteMedia).not.toHaveBeenCalled();
});
it("escape key calls onClose", async () => {
const onClose = vi.fn();
const item = makeImageItem();
await renderPanel({ item, onClose });
await new Promise<void>((resolve) => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
resolve();
});
expect(onClose).toHaveBeenCalled();
});
it("form fields reset when item prop changes", async () => {
const item1 = makeImageItem({ id: "m1", alt: "Alt one", caption: "Cap one" });
const item2 = makeImageItem({ id: "m2", alt: "Alt two", caption: "Cap two" });
const screen = await renderPanel({ item: item1 });
// Verify item1 alt is shown
const altInput = screen.getByLabelText("Alt Text");
await expect.element(altInput).toHaveValue("Alt one");
// Rerender with item2
await screen.rerender(
<QueryWrapper>
<MediaDetailPanel item={item2} onClose={vi.fn()} onDeleted={vi.fn()} />
</QueryWrapper>,
);
// The alt text should now show item2's alt
await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Alt two");
});
});

View File

@@ -0,0 +1,185 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MediaLibrary } from "../../src/components/MediaLibrary";
import type { MediaItem } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const UPLOAD_CTA_PATTERN = /Upload images, videos, and documents/;
const UPLOAD_TO_LIBRARY_PATTERN = /Upload to Library/;
const UPLOAD_FILES_PATTERN = /Upload Files/;
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchMediaProviders: vi.fn().mockResolvedValue([]),
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
uploadToProvider: vi.fn().mockResolvedValue({}),
updateMedia: vi.fn().mockResolvedValue({}),
deleteMedia: vi.fn().mockResolvedValue({}),
};
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
function renderLibrary(props: Partial<React.ComponentProps<typeof MediaLibrary>> = {}) {
const defaultProps: React.ComponentProps<typeof MediaLibrary> = {
items: [],
isLoading: false,
onUpload: vi.fn(),
onSelect: vi.fn(),
onDelete: vi.fn(),
onItemUpdated: vi.fn(),
...props,
};
return render(
<QueryWrapper>
<MediaLibrary {...defaultProps} />
</QueryWrapper>,
);
}
function makeMediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "media_01",
filename: "photo.jpg",
mimeType: "image/jpeg",
url: "https://example.com/photo.jpg",
size: 102400,
width: 800,
height: 600,
createdAt: "2025-01-01T00:00:00Z",
...overrides,
};
}
describe("MediaLibrary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("rendering items", () => {
it("displays media items in grid view by default", async () => {
const items = [
makeMediaItem({ id: "1", filename: "image1.jpg" }),
makeMediaItem({ id: "2", filename: "image2.jpg" }),
];
const screen = await renderLibrary({ items });
// Grid view is default — items render as buttons with alt text
await expect.element(screen.getByRole("button", { name: "Grid view" })).toBeInTheDocument();
// Images should be present via their img alt attributes
await expect.element(screen.getByAltText("image1.jpg")).toBeInTheDocument();
await expect.element(screen.getByAltText("image2.jpg")).toBeInTheDocument();
});
it("grid items show image thumbnails for image mimeTypes", async () => {
const items = [makeMediaItem({ id: "1", filename: "pic.jpg", mimeType: "image/jpeg" })];
const screen = await renderLibrary({ items });
const img = screen.getByAltText("pic.jpg");
await expect.element(img).toBeInTheDocument();
await expect.element(img).toHaveAttribute("src", "https://example.com/photo.jpg");
});
});
describe("view mode toggle", () => {
it("switches between grid and list view", async () => {
const items = [makeMediaItem({ id: "1", filename: "test.jpg" })];
const screen = await renderLibrary({ items });
// Default is grid
const listBtn = screen.getByRole("button", { name: "List view" });
await listBtn.click();
// In list view, filename appears in table cell
await expect.element(screen.getByText("test.jpg")).toBeInTheDocument();
// Table headers should be visible
await expect.element(screen.getByText("Filename")).toBeInTheDocument();
await expect.element(screen.getByText("Type")).toBeInTheDocument();
await expect.element(screen.getByText("Size")).toBeInTheDocument();
});
});
describe("upload", () => {
it("upload button triggers file input", async () => {
const screen = await renderLibrary();
// The upload button should be present
await expect
.element(screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }))
.toBeInTheDocument();
// Hidden file input should exist
const fileInput = screen.getByLabelText("Upload files");
await expect.element(fileInput).toBeInTheDocument();
});
});
describe("item selection", () => {
it("clicking an item opens detail panel", async () => {
const items = [makeMediaItem({ id: "1", filename: "photo.jpg", alt: "A photo" })];
const screen = await renderLibrary({ items });
// Click the grid item button
await screen.getByRole("button", { name: "photo.jpg" }).click();
// MediaDetailPanel should open showing the item details
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();
});
});
describe("empty state", () => {
it("shows upload CTA when no items", async () => {
const screen = await renderLibrary({ items: [] });
await expect.element(screen.getByText("No media yet")).toBeInTheDocument();
await expect.element(screen.getByText(UPLOAD_CTA_PATTERN)).toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }))
.toBeInTheDocument();
});
});
describe("loading state", () => {
it("displays loading state", async () => {
const screen = await renderLibrary({ isLoading: true });
// When loading, neither empty state nor items are shown
expect(screen.getByText("No media yet").query()).toBeNull();
});
});
describe("list view details", () => {
it("list view shows table with filename and details", async () => {
const items = [
makeMediaItem({
id: "1",
filename: "document.pdf",
mimeType: "application/pdf",
size: 1048576,
}),
];
const screen = await renderLibrary({ items });
// Switch to list view
await screen.getByRole("button", { name: "List view" }).click();
await expect.element(screen.getByText("document.pdf")).toBeInTheDocument();
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
await expect.element(screen.getByText("1 MB")).toBeInTheDocument();
});
});
describe("header", () => {
it("shows Media Library heading", async () => {
const screen = await renderLibrary();
await expect.element(screen.getByText("Media Library")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,293 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MediaPickerModal } from "../../src/components/MediaPickerModal";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const UPLOAD_BUTTON_REGEX = /Upload/;
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchMediaList: vi.fn().mockResolvedValue({
items: [
{
id: "m1",
filename: "photo.jpg",
mimeType: "image/jpeg",
url: "/media/photo.jpg",
size: 1024,
width: 800,
height: 600,
createdAt: "2024-01-01",
},
{
id: "m2",
filename: "landscape.png",
mimeType: "image/png",
url: "/media/landscape.png",
size: 2048,
width: 1200,
height: 800,
createdAt: "2024-01-02",
},
],
}),
fetchMediaProviders: vi.fn().mockResolvedValue([]),
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
uploadMedia: vi.fn().mockResolvedValue({ id: "m3", filename: "new.jpg" }),
uploadToProvider: vi.fn().mockResolvedValue({}),
updateMedia: vi.fn().mockResolvedValue({}),
};
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
function renderModal(props: Partial<React.ComponentProps<typeof MediaPickerModal>> = {}) {
const defaultProps: React.ComponentProps<typeof MediaPickerModal> = {
open: true,
onOpenChange: vi.fn(),
onSelect: vi.fn(),
...props,
};
return render(
<QueryWrapper>
<MediaPickerModal {...defaultProps} />
</QueryWrapper>,
);
}
describe("MediaPickerModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("displaying items", () => {
it("shows media items when open", async () => {
const screen = await renderModal({ open: true });
await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument();
await expect
.element(screen.getByRole("option", { name: "landscape.png" }))
.toBeInTheDocument();
});
it("shows the modal title", async () => {
const screen = await renderModal({ title: "Pick an Image" });
await expect.element(screen.getByText("Pick an Image")).toBeInTheDocument();
});
});
describe("selection", () => {
it("single click selects item (highlighted)", async () => {
const screen = await renderModal();
const option = screen.getByRole("option", { name: "photo.jpg" });
await expect.element(option).toBeInTheDocument();
// Direct DOM click to bypass inert overlay
const btn = option.element().querySelector("button")!;
btn.click();
// Should show selected state via aria-selected
await expect.element(option).toHaveAttribute("aria-selected", "true");
// Footer should show selected filename in a <strong> tag
await expect.element(screen.getByRole("strong")).toBeInTheDocument();
});
it("double click selects and calls onSelect", async () => {
const onSelect = vi.fn();
const screen = await renderModal({ onSelect });
const option = screen.getByRole("option", { name: "photo.jpg" });
await expect.element(option).toBeInTheDocument();
// Use direct DOM dblclick to bypass inert overlay
const btn = option.element().querySelector("button")!;
btn.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
);
});
it("Insert button disabled when nothing selected", async () => {
await renderModal();
// There are two Insert buttons — URL section and footer.
// The footer Insert is the last one and should be disabled.
await vi.waitFor(() => {
const allInsertBtns = document.querySelectorAll("button");
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
// The footer Insert (last one) should be disabled
const lastInsert = insertBtns.at(-1);
expect(lastInsert?.disabled).toBe(true);
});
});
it("Insert button enabled when item selected, calls onSelect", async () => {
const onSelect = vi.fn();
const screen = await renderModal({ onSelect });
// Select an item via direct DOM click
const option = screen.getByRole("option", { name: "photo.jpg" });
await expect.element(option).toBeInTheDocument();
const itemBtn = option.element().querySelector("button")!;
itemBtn.click();
// Wait for selection to register
await expect.element(option).toHaveAttribute("aria-selected", "true");
// Click the footer Insert button (last Insert button)
await vi.waitFor(() => {
const allInsertBtns = document.querySelectorAll("button");
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
const lastInsert = insertBtns.at(-1)!;
expect(lastInsert.disabled).toBe(false);
lastInsert.click();
});
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
);
});
});
describe("URL input", () => {
it("invalid URL shows error", async () => {
const screen = await renderModal();
// The URL input has aria-label "Image URL"
const urlInput = screen.getByLabelText("Image URL");
await expect.element(urlInput).toBeInTheDocument();
// Type an invalid URL — use direct DOM since we're inside a dialog
const inputEl = urlInput.element() as HTMLInputElement;
// Manually set value and trigger change
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(inputEl, "not-a-url");
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
// Click the URL Insert button (first Insert button)
await vi.waitFor(() => {
const urlInsert = [...document.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Insert",
)!;
expect(urlInsert.disabled).toBe(false);
urlInsert.click();
});
await expect.element(screen.getByText("Please enter a valid URL")).toBeInTheDocument();
});
it("URL input: typing a URL and submitting triggers probe", async () => {
const onSelect = vi.fn();
const screen = await renderModal({ onSelect });
const urlInput = screen.getByLabelText("Image URL");
const inputEl = urlInput.element() as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(inputEl, "https://example.com/test.jpg");
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
// Click URL Insert button
await vi.waitFor(() => {
const urlInsert = [...document.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Insert",
)!;
urlInsert.click();
});
// Image probe will fail in test env, so either onSelect called or error shown
await vi.waitFor(
() => {
const called = onSelect.mock.calls.length > 0;
const hasError =
document.body.textContent?.includes("Could not load image from URL") ?? false;
expect(called || hasError).toBe(true);
},
{ timeout: 3000 },
);
});
});
describe("cancel and close", () => {
it("Cancel closes modal", async () => {
const onOpenChange = vi.fn();
const screen = await renderModal({ onOpenChange });
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
// Direct DOM click to bypass inert overlay
const cancelEl = screen.getByText("Cancel").element();
const cancelBtn = cancelEl.closest("button")!;
cancelBtn.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("state reset", () => {
it("state resets when modal reopens", async () => {
const onSelect = vi.fn();
const onOpenChange = vi.fn();
const screen = await renderModal({ open: true, onSelect, onOpenChange });
// Select an item
const option = screen.getByRole("option", { name: "photo.jpg" });
await expect.element(option).toBeInTheDocument();
const btn = option.element().querySelector("button")!;
btn.click();
// Verify selection
await expect.element(option).toHaveAttribute("aria-selected", "true");
// Close modal
await screen.rerender(
<QueryWrapper>
<MediaPickerModal open={false} onOpenChange={onOpenChange} onSelect={onSelect} />
</QueryWrapper>,
);
// Reopen modal
await screen.rerender(
<QueryWrapper>
<MediaPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
</QueryWrapper>,
);
// Footer Insert should be disabled (no selection after reset)
await vi.waitFor(() => {
const allInsertBtns = document.querySelectorAll("button");
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
const lastInsert = insertBtns.at(-1);
expect(lastInsert?.disabled).toBe(true);
});
});
});
describe("upload", () => {
it("upload button and file input are present", async () => {
const screen = await renderModal();
await expect
.element(screen.getByRole("button", { name: UPLOAD_BUTTON_REGEX }))
.toBeInTheDocument();
await expect.element(screen.getByLabelText("Upload file")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,222 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MenuEditor } from "../../src/components/MenuEditor";
import { render } from "../utils/render.tsx";
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({
children,
to,
...props
}: {
children: React.ReactNode;
to?: string;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
useParams: () => ({ name: "main-menu" }),
useNavigate: () => vi.fn(),
};
});
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchMenu: vi.fn(),
createMenuItem: vi.fn().mockResolvedValue({ id: "3" }),
deleteMenuItem: vi.fn().mockResolvedValue(undefined),
updateMenuItem: vi.fn().mockResolvedValue({}),
reorderMenuItems: vi.fn().mockResolvedValue([]),
};
});
import * as api from "../../src/lib/api";
const ADD_CUSTOM_LINK_REGEX = /Add Custom Link/;
const defaultMenu = {
id: "menu1",
name: "main-menu",
label: "Main Menu",
created_at: "",
updated_at: "",
items: [
{
id: "1",
menu_id: "menu1",
parent_id: null,
sort_order: 0,
type: "custom",
reference_collection: null,
reference_id: null,
custom_url: "/",
label: "Home",
title_attr: null,
target: "_self",
css_classes: null,
created_at: "",
},
{
id: "2",
menu_id: "menu1",
parent_id: null,
sort_order: 1,
type: "custom",
reference_collection: null,
reference_id: null,
custom_url: "/about",
label: "About",
title_attr: null,
target: "_self",
css_classes: null,
created_at: "",
},
],
};
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<Toasty>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</Toasty>
);
}
describe("MenuEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.fetchMenu).mockResolvedValue(defaultMenu);
});
it("displays menu items in order", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
// Use exact: true to avoid matching "/about" which contains "About"
await expect.element(screen.getByText("Home")).toBeInTheDocument();
await expect.element(screen.getByText("About", { exact: true })).toBeInTheDocument();
});
it("add item button opens dialog with label and URL inputs", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
await expect.element(screen.getByLabelText("URL")).toBeInTheDocument();
});
it("edit item opens dialog", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByText("Home")).toBeInTheDocument();
const editButtons = screen.getByRole("button", { name: "Edit" });
await editButtons.first().click();
await expect
.element(screen.getByRole("heading", { name: "Edit Menu Item" }))
.toBeInTheDocument();
});
it("delete item fires immediately without confirmation dialog", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByText("Home")).toBeInTheDocument();
// Delete buttons have aria-label="Delete"
const deleteBtn = screen.getByRole("button", { name: "Delete" });
await deleteBtn.first().click();
// No confirmation dialog should appear
expect(screen.getByText("Are you sure").query()).toBeNull();
expect(screen.getByText("Confirm").query()).toBeNull();
});
it("up/down reorder buttons — first item up disabled, last item down disabled", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByText("Home")).toBeInTheDocument();
const disabledButtons = document.querySelectorAll("button[disabled]");
// At least 2: first item's up + last item's down
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
});
it("empty state when no items", async () => {
vi.mocked(api.fetchMenu).mockResolvedValue({
...defaultMenu,
items: [],
});
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByText("No menu items yet")).toBeInTheDocument();
await expect
.element(screen.getByText("Add links to build your navigation menu"))
.toBeInTheDocument();
});
it("shows menu label as heading", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
});
it("shows custom URLs for custom link items", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await expect.element(screen.getByText("Home")).toBeInTheDocument();
await expect.element(screen.getByText("/about")).toBeInTheDocument();
});
it("URL input accepts relative paths", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
const urlInput = screen.getByLabelText("URL");
await urlInput.fill("/about");
// The input should accept the value without browser validation errors
const inputEl = urlInput.element() as HTMLInputElement;
expect(inputEl.validity.valid).toBe(true);
});
it("URL input accepts absolute https URLs", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
const urlInput = screen.getByLabelText("URL");
await urlInput.fill("https://example.com");
const inputEl = urlInput.element() as HTMLInputElement;
expect(inputEl.validity.valid).toBe(true);
});
it("URL input rejects bare domains without scheme", async () => {
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
const urlInput = screen.getByLabelText("URL");
await urlInput.fill("example.com");
const inputEl = urlInput.element() as HTMLInputElement;
expect(inputEl.validity.valid).toBe(false);
});
});

View File

@@ -0,0 +1,144 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MenuList } from "../../src/components/MenuList";
import { render } from "../utils/render.tsx";
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({
children,
to,
...props
}: {
children: React.ReactNode;
to?: string;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchMenus: vi.fn(),
createMenu: vi.fn().mockResolvedValue({ name: "new-menu", label: "New Menu" }),
deleteMenu: vi.fn().mockResolvedValue(undefined),
};
});
import * as api from "../../src/lib/api";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAIN_MENU_ITEMS_REGEX = /main.*3 items/;
const FOOTER_MENU_ITEMS_REGEX = /footer.*1 items/;
const DELETE_MENU_CONFIRMATION_REGEX = /Are you sure you want to delete this menu/;
const CREATE_MENU_REGEX = /Create Menu/;
function mockMenus() {
vi.mocked(api.fetchMenus).mockResolvedValue([
{
id: "m1",
name: "main",
label: "Main Menu",
itemCount: 3,
created_at: "",
updated_at: "",
},
{
id: "m2",
name: "footer",
label: "Footer Menu",
itemCount: 1,
created_at: "",
updated_at: "",
},
]);
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<Toasty>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</Toasty>
);
}
describe("MenuList", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMenus();
});
it("displays list of menus with labels and item counts", async () => {
const screen = await render(<MenuList />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
await expect.element(screen.getByText(MAIN_MENU_ITEMS_REGEX)).toBeInTheDocument();
await expect.element(screen.getByRole("heading", { name: "Footer Menu" })).toBeInTheDocument();
await expect.element(screen.getByText(FOOTER_MENU_ITEMS_REGEX)).toBeInTheDocument();
});
it("Create Menu button opens dialog", async () => {
const screen = await render(<MenuList />, { wrapper: Wrapper });
await screen.getByRole("button", { name: CREATE_MENU_REGEX }).click();
await expect.element(screen.getByText("Create New Menu")).toBeInTheDocument();
});
it("create dialog has name and label inputs", async () => {
const screen = await render(<MenuList />, { wrapper: Wrapper });
await screen.getByRole("button", { name: CREATE_MENU_REGEX }).click();
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
});
it("delete button opens confirmation dialog", async () => {
const screen = await render(<MenuList />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
await screen.getByRole("button", { name: "Delete main menu" }).click();
await expect.element(screen.getByRole("heading", { name: "Delete Menu" })).toBeInTheDocument();
await expect.element(screen.getByText(DELETE_MENU_CONFIRMATION_REGEX)).toBeInTheDocument();
});
it("shows empty state when no menus", async () => {
vi.mocked(api.fetchMenus).mockResolvedValue([]);
const screen = await render(<MenuList />, { wrapper: Wrapper });
await expect.element(screen.getByText("No menus yet")).toBeInTheDocument();
await expect
.element(screen.getByText("Create your first navigation menu to get started"))
.toBeInTheDocument();
});
it("each menu has an Edit link", async () => {
const screen = await render(<MenuList />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
const editLinks = screen.getByText("Edit");
await expect.element(editLinks.first()).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,393 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PluginInfo, AdminManifest } from "../../src/lib/api";
import type { PluginUpdateInfo } from "../../src/lib/api/marketplace";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, params, ...props }: any) => {
let href = String(to ?? "");
if (params && typeof params === "object") {
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
const stringified =
value == null
? ""
: typeof value === "string" || typeof value === "number"
? String(value)
: "";
if (key === "_splat") {
href = href.replace("$", stringified);
} else {
href = href.replace(`$${key}`, stringified);
}
}
}
return (
<a href={href} {...props}>
{children}
</a>
);
},
useNavigate: () => vi.fn(),
};
});
const mockFetchPlugins = vi.fn<() => Promise<PluginInfo[]>>();
const mockEnablePlugin = vi.fn();
const mockDisablePlugin = vi.fn();
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchPlugins: (...args: unknown[]) => mockFetchPlugins(...(args as [])),
enablePlugin: (...args: unknown[]) => mockEnablePlugin(...(args as [])),
disablePlugin: (...args: unknown[]) => mockDisablePlugin(...(args as [])),
};
});
const mockCheckPluginUpdates = vi.fn<() => Promise<PluginUpdateInfo[]>>();
const mockUpdateMarketplacePlugin = vi.fn<() => Promise<void>>();
const mockUninstallMarketplacePlugin = vi.fn<() => Promise<void>>();
vi.mock("../../src/lib/api/marketplace", async () => {
const actual = await vi.importActual("../../src/lib/api/marketplace");
return {
...actual,
checkPluginUpdates: (...args: unknown[]) => mockCheckPluginUpdates(...(args as [])),
updateMarketplacePlugin: (...args: unknown[]) => mockUpdateMarketplacePlugin(...(args as [])),
uninstallMarketplacePlugin: (...args: unknown[]) =>
mockUninstallMarketplacePlugin(...(args as [])),
};
});
// Import after mocks
const { PluginManager } = await import("../../src/components/PluginManager");
function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
return {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
enabled: true,
status: "active",
capabilities: ["hooks"],
hasAdminPages: false,
hasDashboardWidgets: false,
hasHooks: true,
...overrides,
};
}
function makeManifest(overrides: Partial<AdminManifest> = {}): AdminManifest {
return {
version: "1.0.0",
hash: "abc",
collections: {},
plugins: {},
taxonomies: [],
authMode: "passkey",
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<Toasty>{children}</Toasty>
</QueryClientProvider>
);
}
describe("PluginManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "audit-log",
name: "Audit Log",
version: "1.0.0",
enabled: true,
hasAdminPages: true,
capabilities: ["hooks", "pages"],
}),
makePlugin({
id: "seo",
name: "SEO Helper",
version: "2.0.0",
enabled: false,
status: "inactive",
hasAdminPages: false,
capabilities: ["hooks"],
}),
]);
mockEnablePlugin.mockResolvedValue({});
mockDisablePlugin.mockResolvedValue({});
mockCheckPluginUpdates.mockResolvedValue([]);
mockUpdateMarketplacePlugin.mockResolvedValue(undefined);
mockUninstallMarketplacePlugin.mockResolvedValue(undefined);
});
it("displays plugin list with names and versions", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
await expect.element(screen.getByText("v1.0.0")).toBeInTheDocument();
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
await expect.element(screen.getByText("v2.0.0")).toBeInTheDocument();
});
it("enabled plugins show toggle in on state", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
const enableToggle = screen.getByRole("switch", { name: "Disable plugin" });
await expect.element(enableToggle).toBeInTheDocument();
});
it("disabled plugins show toggle in off state", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
const disableToggle = screen.getByRole("switch", { name: "Enable plugin" });
await expect.element(disableToggle).toBeInTheDocument();
});
it("settings link shown only for enabled plugins with admin pages", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
const settingsButtons = screen.getByRole("button", { name: "Settings" }).all();
expect(settingsButtons.length).toBe(1);
});
it("settings link points to the plugin root, not a /settings sub-path", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
const settingsButton = screen.getByRole("button", { name: "Settings" });
await expect.element(settingsButton).toBeInTheDocument();
const anchor = settingsButton.element().closest("a");
expect(anchor).not.toBeNull();
// Plugins are not required to expose a `/settings` sub-page; the gear
// icon should land on the plugin's primary admin page.
expect(anchor!.getAttribute("href")).toMatch(/^\/plugins\/audit-log\/?$/);
});
it("expand/collapse shows plugin details", async () => {
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
const expandButtons = screen.getByRole("button", { name: "Expand details" }).all();
expect(expandButtons.length).toBeGreaterThan(0);
await expandButtons[0]!.click();
await expect.element(screen.getByText("Capabilities")).toBeInTheDocument();
await vi.waitFor(() => {
const badges = document.querySelectorAll(".inline-flex.items-center.rounded-md.bg-kumo-tint");
expect(badges.length).toBeGreaterThanOrEqual(2);
});
});
it("empty state when no plugins", async () => {
mockFetchPlugins.mockResolvedValue([]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
await expect
.element(
screen.getByText("Add plugins to your astro.config.mjs to extend EmDash functionality."),
)
.toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Marketplace features
// -----------------------------------------------------------------------
it("shows Marketplace link when manifest has marketplace URL", async () => {
const screen = await render(
<Wrapper>
<PluginManager
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
/>
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
await expect.element(screen.getByText("Marketplace")).toBeInTheDocument();
});
it("hides Marketplace link when no marketplace configured", async () => {
const screen = await render(
<Wrapper>
<PluginManager manifest={makeManifest()} />
</Wrapper>,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
const marketplaceLink = screen.getByText("Marketplace");
await expect.element(marketplaceLink).not.toBeInTheDocument();
});
it("shows Marketplace badge on marketplace-installed plugins", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "mp-plugin",
name: "Marketplace Plugin",
source: "marketplace",
marketplaceVersion: "1.2.0",
}),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Marketplace Plugin")).toBeInTheDocument();
// Look for the "Marketplace" badge
const badges = screen.getByText("Marketplace").all();
// At least one should be the source badge on the card (not the nav link)
expect(badges.length).toBeGreaterThanOrEqual(1);
});
it("shows 'Check for updates' button when marketplace plugins exist", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "mp-plugin",
name: "MP Plugin",
source: "marketplace",
}),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Check for updates")).toBeInTheDocument();
});
it("hides 'Check for updates' button when no marketplace plugins", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({ id: "config-plugin", name: "Config Plugin", source: "config" }),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("Config Plugin")).toBeInTheDocument();
const checkBtn = screen.getByText("Check for updates");
await expect.element(checkBtn).not.toBeInTheDocument();
});
it("shows marketplace source in expanded details", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "mp-plugin",
name: "MP Plugin",
source: "marketplace",
marketplaceVersion: "1.5.0",
}),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
// Expand
const expandBtn = screen.getByRole("button", { name: "Expand details" });
await expandBtn.click();
await expect
.element(screen.getByText("Installed from marketplace (v1.5.0)"))
.toBeInTheDocument();
});
it("shows uninstall button for marketplace plugins in expanded details", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "mp-plugin",
name: "MP Plugin",
source: "marketplace",
}),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
// Expand
const expandBtn = screen.getByRole("button", { name: "Expand details" });
await expandBtn.click();
await expect.element(screen.getByText("Uninstall")).toBeInTheDocument();
});
it("uninstall button opens confirmation dialog", async () => {
mockFetchPlugins.mockResolvedValue([
makePlugin({
id: "mp-plugin",
name: "MP Plugin",
source: "marketplace",
}),
]);
const screen = await render(
<Wrapper>
<PluginManager />
</Wrapper>,
);
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
const expandBtn = screen.getByRole("button", { name: "Expand details" });
await expandBtn.click();
await screen.getByText("Uninstall").click();
// Confirm dialog
await expect.element(screen.getByText("Uninstall MP Plugin?")).toBeInTheDocument();
await expect
.element(screen.getByText("This will remove the plugin and its bundle from your site."))
.toBeInTheDocument();
await expect.element(screen.getByText("Also delete plugin storage data")).toBeInTheDocument();
});
it("empty state mentions marketplace when configured", async () => {
mockFetchPlugins.mockResolvedValue([]);
const screen = await render(
<Wrapper>
<PluginManager
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
/>
</Wrapper>,
);
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
// The empty state links to the marketplace
await expect.element(screen.getByText("marketplace", { exact: true })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { RepeaterField } from "../../src/components/RepeaterField";
import { render } from "../utils/render.tsx";
describe("RepeaterField", () => {
describe("datetime sub-field", () => {
it("displays a stored ISO datetime in the datetime-local input", async () => {
// Mirrors the top-level datetime widget contract: full ISO 8601
// values must round-trip through `<input type="datetime-local">`,
// which only accepts `YYYY-MM-DDTHH:mm`.
const screen = await render(
<RepeaterField
label="Recalls"
id="recalls"
value={[{ recall_date: "2026-02-26T09:30:00.000Z" }]}
onChange={vi.fn()}
subFields={[{ slug: "recall_date", type: "datetime", label: "Recall date" }]}
/>,
);
const input = screen.getByLabelText("Recall date");
await expect.element(input).toHaveValue("2026-02-26T09:30");
});
it("emits a full ISO 8601 value with Z and milliseconds on change", async () => {
const onChange = vi.fn();
const screen = await render(
<RepeaterField
label="Recalls"
id="recalls"
value={[{ recall_date: "" }]}
onChange={onChange}
subFields={[{ slug: "recall_date", type: "datetime", label: "Recall date" }]}
/>,
);
const input = screen.getByLabelText("Recall date");
await input.fill("2026-02-26T09:30");
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ recall_date: "2026-02-26T09:30:00.000Z" }),
]);
});
});
});

View File

@@ -0,0 +1,486 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import { RevisionHistory } from "../../src/components/RevisionHistory";
import type { Revision, RevisionListResponse } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// Mock the API module
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchRevisions: vi.fn(),
restoreRevision: vi.fn(),
};
});
// Import mocked functions for test control
import { fetchRevisions, restoreRevision } from "../../src/lib/api";
const mockFetchRevisions = fetchRevisions as Mock;
const mockRestoreRevision = restoreRevision as Mock;
const REVISIONS_BUTTON_REGEX = /Revisions/i;
const RESTORE_BUTTON_REGEX = /Restore this version/i;
const TIME_REGEX_5_MINS = /5 mins ago/;
const TIME_REGEX_3_HOURS = /3 hours ago/;
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<Toasty>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</Toasty>
);
}
function makeRevision(overrides: Partial<Revision> = {}): Revision {
return {
id: "rev-1",
collection: "posts",
entryId: "entry-1",
data: { title: "Hello World", body: "Content here" },
authorId: "user-1",
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
...overrides,
};
}
function makeRevisionList(revisions: Revision[]): RevisionListResponse {
return { items: revisions, total: revisions.length };
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("RevisionHistory", () => {
// ---- Starts collapsed ----
it("starts collapsed with only header visible", async () => {
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await expect.element(screen.getByText("Revisions")).toBeInTheDocument();
// The expanded content should not be visible
const noRevisionsText = screen.getByText("No revisions yet");
await expect.element(noRevisionsText).not.toBeInTheDocument();
});
// ---- Query only fires when expanded ----
it("does not fetch revisions until expanded", async () => {
await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
// Should not have called fetchRevisions while collapsed
expect(mockFetchRevisions).not.toHaveBeenCalled();
});
it("fetches revisions when header is clicked to expand", async () => {
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
// Click header to expand
const header = screen.getByText("Revisions");
await header.click();
expect(mockFetchRevisions).toHaveBeenCalledWith("posts", "entry-1", { limit: 20 });
});
// ---- Shows loading state ----
it("shows loading state when expanded and fetching", async () => {
// Never resolve — keeps it in loading state
mockFetchRevisions.mockReturnValue(new Promise(() => {}));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByRole("button", { name: REVISIONS_BUTTON_REGEX }).click();
// Loader renders an SVG spinner — verify the loading container is present
// and that none of the "loaded" states are showing
const emptyText = screen.getByText("No revisions yet");
await expect.element(emptyText).not.toBeInTheDocument();
const errorText = screen.getByText("Failed to load revisions");
await expect.element(errorText).not.toBeInTheDocument();
});
// ---- Shows revision list with relative times ----
it("shows revision list with relative times", async () => {
const revisions = [
makeRevision({
id: "rev-1",
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 mins ago
}),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), // 3 hours ago
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText(TIME_REGEX_5_MINS)).toBeInTheDocument();
await expect.element(screen.getByText(TIME_REGEX_3_HOURS)).toBeInTheDocument();
});
// ---- First revision has "Current" badge ----
it("shows 'Current' badge on the first (latest) revision", async () => {
const revisions = [
makeRevision({ id: "rev-1" }),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Current")).toBeInTheDocument();
});
// ---- First revision does NOT have restore button ----
it("does not show restore button on the latest revision", async () => {
const revisions = [makeRevision({ id: "rev-1" })];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
// Wait for revision to appear
await expect.element(screen.getByText("Current")).toBeInTheDocument();
// The restore button has title "Restore this version" — should not exist for latest
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
await expect.element(restoreButton).not.toBeInTheDocument();
});
// ---- Non-latest revisions have restore button ----
it("shows restore button on non-latest revisions", async () => {
const revisions = [
makeRevision({ id: "rev-1" }),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
// Wait for revisions to load
await expect.element(screen.getByText("Current")).toBeInTheDocument();
// There should be a restore button for the non-latest revision
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
await expect.element(restoreButton).toBeInTheDocument();
});
// ---- Clicking revision toggles selection (shows content snapshot) ----
it("toggles content snapshot when clicking a revision", async () => {
const revisions = [makeRevision({ id: "rev-1", data: { title: "Latest content" } })];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Current")).toBeInTheDocument();
// Content snapshot should not be visible initially
const snapshotLabel = screen.getByText("Content snapshot:");
await expect.element(snapshotLabel).not.toBeInTheDocument();
// Click the revision to select it
const revisionButton = screen.getByText("Current").element().closest("button")!;
await userEvent.click(revisionButton);
// Content snapshot should now be visible
await expect.element(screen.getByText("Content snapshot:")).toBeInTheDocument();
// Click again to deselect
await userEvent.click(revisionButton);
await expect.element(screen.getByText("Content snapshot:")).not.toBeInTheDocument();
});
// ---- Restore calls confirm() then restoreRevision ----
it("opens confirm dialog then restoreRevision when confirmed", async () => {
mockRestoreRevision.mockResolvedValue({});
const onRestored = vi.fn();
const revisions = [
makeRevision({ id: "rev-1" }),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" onRestored={onRestored} />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Current")).toBeInTheDocument();
// Click the restore button on the second revision
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
await restoreButton.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Restore Revision?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Restore" }).element().click();
await vi.waitFor(() => {
expect(mockRestoreRevision).toHaveBeenCalledWith("rev-2");
});
});
it("does not call restoreRevision when confirm dialog is cancelled", async () => {
const revisions = [
makeRevision({ id: "rev-1" }),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Current")).toBeInTheDocument();
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
await restoreButton.click();
// ConfirmDialog should appear
await expect.element(screen.getByText("Restore Revision?")).toBeInTheDocument();
// Direct DOM click to bypass Base UI inert overlay
screen.getByRole("button", { name: "Cancel" }).element().click();
expect(mockRestoreRevision).not.toHaveBeenCalled();
});
// ---- Error state ----
it("shows error message when fetching revisions fails", async () => {
mockFetchRevisions.mockRejectedValue(new Error("Network error"));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Failed to load revisions")).toBeInTheDocument();
});
// ---- Empty state ----
it("shows empty state when no revisions exist", async () => {
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("No revisions yet")).toBeInTheDocument();
});
// ---- Collapse hides revision content ----
it("hides revision content when collapsed after expanding", async () => {
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
const headerButton = screen.getByRole("button", { name: REVISIONS_BUTTON_REGEX });
// Expand
await headerButton.click();
await expect.element(screen.getByText("No revisions yet")).toBeInTheDocument();
// Collapse
await headerButton.click();
// Content should be hidden
await expect.element(screen.getByText("No revisions yet")).not.toBeInTheDocument();
});
// ---- Shows total count in header ----
it("shows total count in header when revisions exist", async () => {
const revisions = [
makeRevision({ id: "rev-1" }),
makeRevision({
id: "rev-2",
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
makeRevision({
id: "rev-3",
createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
// Should show (3) next to "Revisions"
await expect.element(screen.getByText("(3)")).toBeInTheDocument();
});
// ---- Visual diff view ----
it("shows visual diff when selecting a non-latest revision", async () => {
const revisions = [
makeRevision({
id: "rev-1",
data: { title: "Updated Title", body: "Same body", newField: "added" },
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
}),
makeRevision({
id: "rev-2",
data: { title: "Original Title", body: "Same body", removed: "gone" },
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
await expect.element(screen.getByText("Current")).toBeInTheDocument();
// Click the second (non-latest) revision
const revisionButtons = screen.getByText("1 day ago").element().closest("button")!;
await userEvent.click(revisionButtons);
// Should show diff, not raw snapshot
await expect
.element(screen.getByText("from next revision", { exact: false }))
.toBeInTheDocument();
// Changed field values should appear in the diff
await expect.element(screen.getByText("Original Title")).toBeInTheDocument();
await expect.element(screen.getByText("Updated Title")).toBeInTheDocument();
});
it("shows raw snapshot for latest revision (no diff target)", async () => {
const revisions = [
makeRevision({
id: "rev-1",
data: { title: "Latest" },
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
}),
makeRevision({
id: "rev-2",
data: { title: "Older" },
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
];
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
const screen = await render(
<QueryWrapper>
<RevisionHistory collection="posts" entryId="entry-1" />
</QueryWrapper>,
);
await screen.getByText("Revisions").click();
// Click the latest revision
const latestButton = screen.getByText("Current").element().closest("button")!;
await userEvent.click(latestButton);
// Should show raw JSON snapshot, not diff
await expect.element(screen.getByText("Content snapshot:")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { describe, it, expect } from "vitest";
import { SaveButton } from "../../src/components/SaveButton";
import { render } from "../utils/render.tsx";
describe("SaveButton", () => {
it("shows 'Save' when dirty and not saving", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
await expect.element(screen.getByText("Save")).toBeInTheDocument();
await expect.element(screen.getByRole("button")).toBeEnabled();
});
it("shows 'Saving...' when saving", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={true} />);
await expect.element(screen.getByText("Saving...")).toBeInTheDocument();
await expect.element(screen.getByRole("button")).toBeDisabled();
});
it("shows 'Saved' when not dirty and not saving", async () => {
const screen = await render(<SaveButton isDirty={false} isSaving={false} />);
await expect.element(screen.getByText("Saved")).toBeInTheDocument();
await expect.element(screen.getByRole("button")).toBeDisabled();
});
it("has aria-busy when saving", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={true} />);
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
});
it("does not have aria-busy when not saving", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-busy", "false");
});
it("has aria-live polite", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-live", "polite");
});
it("respects external disabled prop", async () => {
const screen = await render(<SaveButton isDirty={true} isSaving={false} disabled={true} />);
await expect.element(screen.getByRole("button")).toBeDisabled();
});
});

View File

@@ -0,0 +1,126 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Section } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// Capture props passed to PortableTextEditor so the test can invoke the
// block-sidebar callbacks the way the image node view does at runtime.
const portableTextProps: { current: Record<string, unknown> | null } = { current: null };
vi.mock("../../src/components/PortableTextEditor", () => ({
PortableTextEditor: (props: Record<string, unknown>) => {
portableTextProps.current = props;
return <div data-testid="portable-text-editor" />;
},
}));
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...(actual as Record<string, unknown>),
Link: ({ children, to, ...props }: { children: React.ReactNode; to?: string }) => (
<a href={String(to ?? "")} {...props}>
{children}
</a>
),
useParams: () => ({ slug: "footer" }),
useNavigate: () => vi.fn(),
};
});
const mockFetchSection = vi.fn<() => Promise<Section>>();
const mockUpdateSection = vi.fn();
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...(actual as Record<string, unknown>),
fetchSection: (...args: unknown[]) => mockFetchSection(...(args as [])),
updateSection: (...args: unknown[]) => mockUpdateSection(...(args as [])),
};
});
// Import after mocks so the module under test picks up the mocked deps.
const { SectionEditor } = await import("../../src/components/SectionEditor");
function makeSection(overrides: Partial<Section> = {}): Section {
return {
id: "sec_footer",
slug: "footer",
title: "Footer",
description: "All page footer",
keywords: [],
content: [],
source: "user",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<Toasty>{children}</Toasty>
</QueryClientProvider>
);
}
describe("SectionEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
portableTextProps.current = null;
mockFetchSection.mockResolvedValue(makeSection());
});
it("opens the image settings panel when a block requests the sidebar", async () => {
const screen = await render(<SectionEditor />, { wrapper: Wrapper });
// Editor must mount (and capture its props) before we can simulate.
await expect.element(screen.getByTestId("portable-text-editor")).toBeInTheDocument();
const onBlockSidebarOpen = portableTextProps.current?.onBlockSidebarOpen as
| ((panel: unknown) => void)
| undefined;
const onBlockSidebarClose = portableTextProps.current?.onBlockSidebarClose as
| (() => void)
| undefined;
// Both callbacks must be wired — without them, clicking the image-settings
// icon in the editor is a silent no-op (#845).
expect(typeof onBlockSidebarOpen).toBe("function");
expect(typeof onBlockSidebarClose).toBe("function");
// Simulate the image node view asking for sidebar space, the same shape
// it sends from ImageNode.openSidebar(). expect.element below polls until
// React flushes the state update, so we don't need an explicit act wrapper.
onBlockSidebarOpen!({
type: "image",
attrs: {
src: "https://example.com/logo.png",
alt: "Logo",
mediaId: "media-1",
width: 400,
height: 200,
},
onUpdate: vi.fn(),
onReplace: vi.fn(),
onDelete: vi.fn(),
onClose: vi.fn(),
});
// The Image Settings panel should now be rendered in the sidebar slot.
// The panel renders the image preview using the src we passed.
await expect.element(screen.getByRole("img", { name: "Logo" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Section, SectionCategory, SectionsResult } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
const mockFetchSections = vi.fn<() => Promise<SectionsResult>>();
const mockFetchSectionCategories = vi.fn<() => Promise<SectionCategory[]>>();
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchSections: (...args: unknown[]) => mockFetchSections(...(args as [])),
fetchSectionCategories: (...args: unknown[]) => mockFetchSectionCategories(...(args as [])),
};
});
// Import after mocks
const { SectionPickerModal } = await import("../../src/components/SectionPickerModal");
function makeSection(overrides: Partial<Section> = {}): Section {
return {
id: "sec_01",
slug: "hero",
title: "Hero Section",
description: "Main hero",
keywords: [],
content: [],
source: "theme",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("SectionPickerModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSections.mockResolvedValue({
items: [
makeSection({
id: "sec_01",
slug: "hero",
title: "Hero Section",
description: "Main hero",
source: "theme",
}),
makeSection({
id: "sec_02",
slug: "cta",
title: "Call to Action",
description: "CTA block",
source: "user",
}),
],
});
mockFetchSectionCategories.mockResolvedValue([
{ id: "cat_1", slug: "layout", label: "Layout", sortOrder: 0 },
{ id: "cat_2", slug: "marketing", label: "Marketing", sortOrder: 1 },
]);
});
it("shows sections when open", async () => {
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
});
it("clicking a section calls onSelect and closes modal", async () => {
const onSelect = vi.fn();
const onOpenChange = vi.fn();
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
// The Base UI dialog puts an inert overlay. Use direct DOM click to bypass it.
const heroEl = screen.getByText("Hero Section").element();
const button = heroEl.closest("button");
button!.click();
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ slug: "hero", title: "Hero Section" }),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("search input filters results", async () => {
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
const searchInput = screen.getByPlaceholder("Search sections...");
await searchInput.fill("cta");
// Search is debounced — wait for the query to fire
await vi.waitFor(() => {
expect(mockFetchSections).toHaveBeenCalledWith(expect.objectContaining({ search: "cta" }));
});
});
it("cancel button closes modal", async () => {
const onOpenChange = vi.fn();
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
</Wrapper>,
);
await expect.element(screen.getByText("Insert Section")).toBeInTheDocument();
// Direct DOM click to bypass the inert overlay
const cancelEl = screen.getByText("Cancel").element();
cancelEl.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("queries do not fire when closed", async () => {
await render(
<Wrapper>
<SectionPickerModal open={false} onOpenChange={vi.fn()} onSelect={vi.fn()} />
</Wrapper>,
);
// Wait a tick to let any queries that might fire settle
await new Promise((r) => setTimeout(r, 50));
expect(mockFetchSections).not.toHaveBeenCalled();
expect(mockFetchSectionCategories).not.toHaveBeenCalled();
});
it("state resets when modal reopens", async () => {
const onOpenChange = vi.fn();
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
</Wrapper>,
);
// Type something in search
const searchInput = screen.getByPlaceholder("Search sections...");
await searchInput.fill("test");
// Close and reopen
await screen.rerender(
<Wrapper>
<SectionPickerModal open={false} onOpenChange={onOpenChange} onSelect={vi.fn()} />
</Wrapper>,
);
await screen.rerender(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
</Wrapper>,
);
// Search should be reset — the input should have an empty value
await vi.waitFor(() => {
const input = document.querySelector(
'input[placeholder="Search sections..."]',
) as HTMLInputElement | null;
expect(input?.value ?? "").toBe("");
});
});
it("shows empty state messaging when no sections match", async () => {
mockFetchSections.mockResolvedValue({ items: [] });
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
</Wrapper>,
);
await expect.element(screen.getByText("No sections available")).toBeInTheDocument();
});
it("shows filtered empty state when search has no results", async () => {
mockFetchSections.mockResolvedValue({ items: [] });
const screen = await render(
<Wrapper>
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
</Wrapper>,
);
const searchInput = screen.getByPlaceholder("Search sections...");
await searchInput.fill("nonexistent");
await expect.element(screen.getByText("No sections found")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,203 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Section, SectionsResult } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
const mockFetchSections = vi.fn<() => Promise<SectionsResult>>();
const mockCreateSection = vi.fn();
const mockDeleteSection = vi.fn();
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchSections: (...args: unknown[]) => mockFetchSections(...(args as [])),
createSection: (...args: unknown[]) => mockCreateSection(...(args as [])),
deleteSection: (...args: unknown[]) => mockDeleteSection(...(args as [])),
};
});
// Import after mocks
const { Sections } = await import("../../src/components/Sections");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DELETE_SECTION_MSG_REGEX = /This will permanently delete/;
function makeSection(overrides: Partial<Section> = {}): Section {
return {
id: "sec_01",
slug: "hero",
title: "Hero Section",
description: "Main hero",
keywords: [],
content: [],
source: "theme",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
};
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<Toasty>{children}</Toasty>
</QueryClientProvider>
);
}
describe("Sections", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSections.mockResolvedValue({
items: [
makeSection({
id: "sec_01",
slug: "hero",
title: "Hero Section",
description: "Main hero",
source: "theme",
}),
makeSection({
id: "sec_02",
slug: "cta",
title: "Call to Action",
description: "CTA block",
source: "user",
}),
],
});
mockCreateSection.mockResolvedValue(makeSection({ slug: "new-section" }));
mockDeleteSection.mockResolvedValue(undefined);
});
it("displays sections with titles and descriptions", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
await expect.element(screen.getByText("Main hero")).toBeInTheDocument();
await expect.element(screen.getByText("CTA block")).toBeInTheDocument();
});
it("create button opens dialog with title/slug form", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await screen.getByText("New Section").click();
await expect.element(screen.getByText("Create Section")).toBeInTheDocument();
// Check form fields exist — InputArea uses label prop but may not be associated via aria
await expect.element(screen.getByLabelText("Title")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
});
it("auto-generates slug from title in create dialog", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await screen.getByText("New Section").click();
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("My Great Section");
// Slug should be auto-generated
await expect.element(screen.getByLabelText("Slug")).toHaveValue("my-great-section");
});
it("search input filters sections", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
const searchInput = screen.getByPlaceholder("Search sections...");
await searchInput.fill("hero");
// fetchSections will be called again with search param
expect(mockFetchSections).toHaveBeenCalledWith(expect.objectContaining({ search: "hero" }));
});
it("delete button opens confirmation dialog", async () => {
mockFetchSections.mockResolvedValue({
items: [
makeSection({
id: "sec_02",
slug: "cta",
title: "Call to Action",
description: "CTA block",
source: "user",
}),
],
});
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
// Click delete on the user section
const deleteButton = screen.getByTitle("Delete");
await deleteButton.click();
await expect.element(screen.getByText("Delete Section?")).toBeInTheDocument();
await expect.element(screen.getByText(DELETE_SECTION_MSG_REGEX)).toBeInTheDocument();
});
it("theme sections have disabled delete button", async () => {
mockFetchSections.mockResolvedValue({
items: [
makeSection({
id: "sec_01",
slug: "hero",
title: "Hero Section",
source: "theme",
}),
],
});
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
const deleteButton = screen.getByTitle("Cannot delete theme sections");
await expect.element(deleteButton).toBeDisabled();
});
it("each section has an edit button", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
const editButtons = screen.getByText("Edit").all();
expect(editButtons.length).toBe(2);
});
});

View File

@@ -0,0 +1,204 @@
import * as React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { SeoPanel } from "../../src/components/SeoPanel";
import { render } from "../utils/render";
describe("SeoPanel", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("debounces text field saves", async () => {
const onChange = vi.fn();
const screen = await render(
<SeoPanel
contentKey="post-1"
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
onChange={onChange}
/>,
);
const titleInput = screen.getByLabelText("SEO Title");
await userEvent.type(titleInput, "SEO title");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onChange).not.toHaveBeenCalled();
await vi.waitFor(
() => {
expect(onChange).toHaveBeenCalledTimes(1);
},
{ timeout: 1500 },
);
expect(onChange).toHaveBeenLastCalledWith({
title: "SEO title",
description: null,
canonical: null,
noIndex: false,
});
});
it("saves noindex changes immediately", async () => {
const onChange = vi.fn();
const screen = await render(
<SeoPanel
contentKey="post-1"
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
onChange={onChange}
/>,
);
await userEvent.click(screen.getByRole("switch"));
await vi.waitFor(() => {
expect(onChange).toHaveBeenCalledWith({
title: null,
description: null,
canonical: null,
noIndex: true,
});
});
});
it("does not overwrite newer local text when stale props arrive", async () => {
function Host() {
const [seo, setSeo] = React.useState({
title: "Original",
description: null,
image: null,
canonical: null,
noIndex: false,
});
return (
<>
<SeoPanel contentKey="post-1" seo={seo} onChange={() => {}} />
<button
type="button"
onClick={() =>
setSeo({
title: "Older save",
description: null,
image: null,
canonical: null,
noIndex: false,
})
}
>
Apply stale props
</button>
</>
);
}
const screen = await render(<Host />);
const titleInput = screen.getByLabelText("SEO Title");
await userEvent.clear(titleInput);
await userEvent.type(titleInput, "Newest local value");
await vi.waitFor(() => {
expect((titleInput.element() as HTMLInputElement).value).toBe("Newest local value");
});
const stalePropsButton = screen.getByRole("button", { name: "Apply stale props" });
await userEvent.click(stalePropsButton);
expect((titleInput.element() as HTMLInputElement).value).toBe("Newest local value");
});
it("resets when switching to a different content item", async () => {
const onChange = vi.fn();
function Host() {
const [contentKey, setContentKey] = React.useState("post-1");
const [seo, setSeo] = React.useState({
title: "First post",
description: null,
image: null,
canonical: null,
noIndex: false,
});
return (
<>
<SeoPanel contentKey={contentKey} seo={seo} onChange={onChange} />
<button
type="button"
onClick={() => {
setContentKey("post-2");
setSeo({
title: "Second post",
description: "Fresh content",
image: null,
canonical: null,
noIndex: false,
});
}}
>
Switch content
</button>
</>
);
}
const screen = await render(<Host />);
const titleInput = screen.getByLabelText("SEO Title");
await userEvent.clear(titleInput);
await userEvent.type(titleInput, "Unsaved local edit");
await userEvent.click(screen.getByRole("button", { name: "Switch content" }));
expect(onChange).toHaveBeenCalledWith({
title: "Unsaved local edit",
description: null,
canonical: null,
noIndex: false,
});
expect((titleInput.element() as HTMLInputElement).value).toBe("Second post");
await new Promise((resolve) => setTimeout(resolve, 700));
expect(onChange).toHaveBeenCalledTimes(1);
});
it("flushes pending text changes on unmount", async () => {
const onChange = vi.fn();
function Host() {
const [isVisible, setIsVisible] = React.useState(true);
return (
<>
{isVisible ? (
<SeoPanel
contentKey="post-1"
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
onChange={onChange}
/>
) : null}
<button type="button" onClick={() => setIsVisible(false)}>
Hide panel
</button>
</>
);
}
const screen = await render(<Host />);
const titleInput = screen.getByLabelText("SEO Title");
await userEvent.type(titleInput, "SEO title");
await userEvent.click(screen.getByRole("button", { name: "Hide panel" }));
expect(onChange).toHaveBeenCalledWith({
title: "SEO title",
description: null,
canonical: null,
noIndex: false,
});
await new Promise((resolve) => setTimeout(resolve, 700));
expect(onChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,113 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AdminManifest } from "../../src/lib/api";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
const mockFetchManifest = vi.fn<() => Promise<AdminManifest>>();
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchManifest: (...args: unknown[]) => mockFetchManifest(...(args as [])),
};
});
// Import after mocks
const { Settings } = await import("../../src/components/Settings");
const defaultManifest: AdminManifest = {
authMode: "passkey",
collections: {},
plugins: {},
taxonomies: [],
version: "1",
hash: "",
};
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("Settings", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchManifest.mockResolvedValue(defaultManifest);
});
it("displays settings heading", async () => {
const screen = await render(
<Wrapper>
<Settings />
</Wrapper>,
);
await expect.element(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
});
it("shows links to General, Social, and SEO sub-pages", async () => {
const screen = await render(
<Wrapper>
<Settings />
</Wrapper>,
);
await expect.element(screen.getByText("General")).toBeInTheDocument();
await expect.element(screen.getByText("Social Links")).toBeInTheDocument();
await expect.element(screen.getByText("SEO")).toBeInTheDocument();
});
it("shows links to API Tokens and Email sub-pages", async () => {
const screen = await render(
<Wrapper>
<Settings />
</Wrapper>,
);
await expect.element(screen.getByText("API Tokens")).toBeInTheDocument();
await expect.element(screen.getByText("Email", { exact: true })).toBeInTheDocument();
});
it("security link shown when authMode is passkey", async () => {
mockFetchManifest.mockResolvedValue(defaultManifest);
const screen = await render(
<Wrapper>
<Settings />
</Wrapper>,
);
await expect.element(screen.getByText("Security")).toBeInTheDocument();
await expect.element(screen.getByText("Self-Signup Domains")).toBeInTheDocument();
});
it("security link hidden when authMode is not passkey", async () => {
mockFetchManifest.mockResolvedValue({
...defaultManifest,
authMode: "cloudflare-access",
});
const screen = await render(
<Wrapper>
<Settings />
</Wrapper>,
);
// Wait for the page to render by checking a link that's always visible
await expect.element(screen.getByText("General")).toBeInTheDocument();
expect(screen.getByText("Security").query()).toBeNull();
expect(screen.getByText("Self-Signup Domains").query()).toBeNull();
});
});

View File

@@ -0,0 +1,244 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "../utils/render.tsx";
// Mock API
let mockSeedInfo: any = null;
vi.mock("../../src/lib/api/client", async () => {
const actual = await vi.importActual("../../src/lib/api/client");
return {
...actual,
apiFetch: vi.fn().mockImplementation((url: string) => {
if (url.includes("/setup/status")) {
return Promise.resolve(
new Response(
JSON.stringify({
data: {
needsSetup: true,
authMode: "passkey",
...(mockSeedInfo ? { seedInfo: mockSeedInfo } : {}),
},
}),
{
status: 200,
},
),
);
}
if (url.includes("/setup/admin")) {
return Promise.resolve(
new Response(JSON.stringify({ data: { success: true } }), { status: 200 }),
);
}
if (url.includes("/setup") && !url.includes("status")) {
return Promise.resolve(
new Response(JSON.stringify({ data: { success: true } }), { status: 200 }),
);
}
return Promise.resolve(new Response(JSON.stringify({ data: {} }), { status: 200 }));
}),
};
});
// Mock WebAuthn so PasskeyRegistration doesn't bail out
Object.defineProperty(window, "PublicKeyCredential", {
value: function PublicKeyCredential() {},
writable: true,
});
// Import after mocks
const { SetupWizard } = await import("../../src/components/SetupWizard");
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("SetupWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSeedInfo = null;
});
it("shows site setup step first with title input", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
await expect.element(screen.getByPlaceholder("My Awesome Blog")).toBeInTheDocument();
});
it("empty title prevents advancing and shows validation error", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
// Wait for setup status to load
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
// Click Continue without filling title
await screen.getByText("Continue →").click();
await expect.element(screen.getByText("Site title is required")).toBeInTheDocument();
// Should still be on site step
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
});
it("filling title and clicking Next advances to admin step", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
// Fill in the title
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
// Click continue
await screen.getByText("Continue →").click();
// Should advance to admin step
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
});
it("admin step shows email input", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
// Fill title and advance
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
await screen.getByText("Continue →").click();
// Should see email input on admin step
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
await expect.element(screen.getByPlaceholder("you@example.com")).toBeInTheDocument();
});
it("empty email prevents advancing on admin step", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
// Fill title and advance
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
await screen.getByText("Continue →").click();
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
// Click Continue without filling email
await screen.getByText("Continue →").click();
// Should show validation error
await expect.element(screen.getByText("Email is required")).toBeInTheDocument();
});
it("step indicator shows three steps", async () => {
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
// Step indicator labels - use exact matching via role
await expect.element(screen.getByText("Account")).toBeInTheDocument();
await expect.element(screen.getByText("Sign In")).toBeInTheDocument();
});
it("prefills title and tagline from seedInfo", async () => {
mockSeedInfo = {
name: "Blog Template",
description: "A blog template",
collections: 2,
hasContent: true,
title: "My Awesome Blog",
tagline: "Thoughts and tutorials",
};
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
const titleInput = screen.getByPlaceholder("My Awesome Blog");
const taglineInput = screen.getByPlaceholder("Thoughts, tutorials, and more");
await vi.waitFor(() => {
expect((titleInput.element() as HTMLInputElement).value).toBe("My Awesome Blog");
});
await vi.waitFor(() => {
expect((taglineInput.element() as HTMLInputElement).value).toBe("Thoughts and tutorials");
});
});
it("uses empty string for title and tagline when not in seedInfo", async () => {
mockSeedInfo = {
name: "Blank Template",
description: "A blank template",
collections: 0,
hasContent: false,
// title and tagline not provided
};
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
const titleInput = screen.getByPlaceholder("My Awesome Blog");
const taglineInput = screen.getByPlaceholder("Thoughts, tutorials, and more");
await vi.waitFor(() => {
expect((titleInput.element() as HTMLInputElement).value).toBe("");
});
await vi.waitFor(() => {
expect((taglineInput.element() as HTMLInputElement).value).toBe("");
});
});
it("prefilled title can be edited and submitted", async () => {
mockSeedInfo = {
name: "Blog Template",
description: "A blog template",
collections: 2,
hasContent: true,
title: "My Awesome Blog",
tagline: "Thoughts and tutorials",
};
const screen = await render(
<QueryWrapper>
<SetupWizard />
</QueryWrapper>,
);
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
const titleInput = screen.getByPlaceholder("My Awesome Blog");
await vi.waitFor(() => {
expect((titleInput.element() as HTMLInputElement).value).toBe("My Awesome Blog");
});
// Edit the title
await titleInput.fill("My Custom Blog");
await vi.waitFor(() => {
expect((screen.getByPlaceholder("My Awesome Blog").element() as HTMLInputElement).value).toBe(
"My Custom Blog",
);
});
// Should be able to advance with the edited value
await screen.getByText("Continue →").click();
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,156 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "../utils/render.tsx";
// Mock router
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
};
});
// Mock API
const mockRequestSignup = vi.fn().mockResolvedValue({ success: true });
const mockVerifySignupToken = vi
.fn()
.mockResolvedValue({ email: "test@example.com", role: 30, roleName: "Author" });
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
requestSignup: (...args: unknown[]) => mockRequestSignup(...args),
verifySignupToken: (...args: unknown[]) => mockVerifySignupToken(...args),
hasAllowedDomains: vi.fn().mockResolvedValue(true),
};
});
// Mock WebAuthn so PasskeyRegistration doesn't bail out
Object.defineProperty(window, "PublicKeyCredential", {
value: function PublicKeyCredential() {},
writable: true,
});
// Import after mocks
const { SignupPage } = await import("../../src/components/SignupPage");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const RESEND_COOLDOWN_REGEX = /Resend in \d+s/;
describe("SignupPage", () => {
beforeEach(() => {
mockRequestSignup.mockClear();
mockVerifySignupToken.mockClear();
// Clean URL params
window.history.replaceState({}, "", window.location.pathname);
});
it("shows email input initially", async () => {
const screen = await render(<SignupPage />);
await expect.element(screen.getByText("Create an account")).toBeInTheDocument();
await expect.element(screen.getByPlaceholder("you@company.com")).toBeInTheDocument();
});
it("submit empty email shows validation error", async () => {
const screen = await render(<SignupPage />);
await screen.getByText("Continue").click();
await expect.element(screen.getByText("Email is required")).toBeInTheDocument();
});
it("submit invalid email (no dot) shows validation error", async () => {
const screen = await render(<SignupPage />);
const input = screen.getByPlaceholder("you@company.com");
// Use an email with @ but no dot - passes browser validation but fails component validation
await input.fill("test@nodot");
await screen.getByText("Continue").click();
await expect
.element(screen.getByText("Please enter a valid email address"))
.toBeInTheDocument();
});
it("submit valid email advances to check-email step", async () => {
const screen = await render(<SignupPage />);
const input = screen.getByPlaceholder("you@company.com");
await input.fill("test@example.com");
await screen.getByText("Continue").click();
// Should advance to check-email - use the h1 heading to disambiguate
await expect
.element(screen.getByRole("heading", { level: 1, name: "Check your email" }))
.toBeInTheDocument();
});
it("check-email step shows correct email", async () => {
const screen = await render(<SignupPage />);
await screen.getByPlaceholder("you@company.com").fill("test@example.com");
await screen.getByText("Continue").click();
await expect.element(screen.getByText("test@example.com")).toBeInTheDocument();
});
it("submit valid email advances to check-email step", async () => {
const screen = await render(<SignupPage />);
const input = screen.getByPlaceholder("you@company.com");
await input.fill("test@example.com");
await screen.getByText("Continue").click();
// Should advance to check-email - h2 inside the card is unique to this step
await expect.element(screen.getByText("We've sent a verification link to")).toBeInTheDocument();
});
it("check-email step shows correct email", async () => {
const screen = await render(<SignupPage />);
await screen.getByPlaceholder("you@company.com").fill("test@example.com");
await screen.getByText("Continue").click();
await expect.element(screen.getByText("test@example.com")).toBeInTheDocument();
});
it("resend button has cooldown timer", async () => {
mockRequestSignup.mockResolvedValue({ success: true });
const screen = await render(<SignupPage />);
await screen.getByPlaceholder("you@company.com").fill("test@example.com");
await screen.getByText("Continue").click();
// Should see resend button
await expect.element(screen.getByText("Resend email")).toBeInTheDocument();
// Click resend
await screen.getByText("Resend email").click();
// Should show cooldown text
await expect.element(screen.getByText(RESEND_COOLDOWN_REGEX)).toBeInTheDocument();
});
it("error step shows correct heading for token_expired", async () => {
mockVerifySignupToken.mockRejectedValue(
Object.assign(new Error("This link has expired"), { code: "token_expired" }),
);
// Navigate with token in URL
window.history.replaceState({}, "", "?token=expired-token");
const screen = await render(<SignupPage />);
await expect.element(screen.getByText("Link expired")).toBeInTheDocument();
});
it("error step shows correct heading for invalid_token", async () => {
mockVerifySignupToken.mockRejectedValue(
Object.assign(new Error("Invalid token"), { code: "invalid_token" }),
);
window.history.replaceState({}, "", "?token=bad-token");
const screen = await render(<SignupPage />);
await expect.element(screen.getByText("Invalid link")).toBeInTheDocument();
});
it("error step shows correct heading for user_exists", async () => {
mockVerifySignupToken.mockRejectedValue(
Object.assign(new Error("Account already exists"), { code: "user_exists" }),
);
window.history.replaceState({}, "", "?token=exists-token");
const screen = await render(<SignupPage />);
await expect.element(screen.getByText("Account exists")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,225 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TaxonomyManager } from "../../src/components/TaxonomyManager";
import { render } from "../utils/render.tsx";
const taxonomyResponse = JSON.stringify({
data: {
taxonomies: [
{
id: "t1",
name: "categories",
label: "Categories",
labelSingular: "Category",
hierarchical: true,
collections: ["posts"],
},
],
},
});
const termsResponse = JSON.stringify({
data: {
terms: [
{
id: "1",
name: "tech",
slug: "tech",
label: "Technology",
parentId: null,
children: [],
count: 5,
},
{
id: "2",
name: "science",
slug: "science",
label: "Science",
parentId: null,
children: [],
count: 3,
},
],
},
});
vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
...actual,
apiFetch: vi.fn(),
};
});
import { apiFetch } from "../../src/lib/api/client.js";
function mockApiFetch(overrideTerms?: string) {
vi.mocked(apiFetch).mockImplementation((url: string, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : "";
if (urlStr.includes("/terms") && (!init || !init.method || init.method === "GET")) {
return Promise.resolve(
new Response(overrideTerms ?? termsResponse, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (urlStr.includes("/taxonomies") && (!init || !init.method || init.method === "GET")) {
return Promise.resolve(
new Response(taxonomyResponse, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(
new Response(JSON.stringify({ data: { success: true } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<Toasty>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</Toasty>
);
}
const ADD_CATEGORY_BUTTON_REGEX = /Add Category/;
const ADD_CATEGORY_HEADING_REGEX = /Add Category/;
const EDIT_CATEGORY_HEADING_REGEX = /Edit Category/;
const PARENT_SELECTOR_REGEX = /Parent/;
const NO_CATEGORIES_REGEX = /No categories yet/;
const DELETE_CATEGORY_HEADING_REGEX = /Delete Category/i;
const DELETE_TECHNOLOGY_DESC_REGEX = /permanently delete "Technology"/;
describe("TaxonomyManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiFetch();
});
it("displays taxonomy name as heading", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
});
it("shows list of terms with labels", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
// Use locators that target the specific label spans (font-medium class)
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
// "Science" also appears in "(science)" slug, so target the font-medium span
await expect.element(screen.getByText("(science)")).toBeInTheDocument();
});
it("shows term slugs in parentheses", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByText("(tech)")).toBeInTheDocument();
await expect.element(screen.getByText("(science)")).toBeInTheDocument();
});
it("add button opens create dialog", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
// Wait for content to load, then click the button
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
// Verify the dialog heading opened
await expect
.element(screen.getByRole("heading", { name: ADD_CATEGORY_HEADING_REGEX }))
.toBeInTheDocument();
});
it("create dialog has name, slug, and description inputs", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
// The InputArea uses "Description (optional)" as label
await expect.element(screen.getByText("Description (optional)")).toBeInTheDocument();
});
it("shows parent selector for hierarchical taxonomies", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
await expect.element(screen.getByLabelText(PARENT_SELECTOR_REGEX)).toBeInTheDocument();
});
it("edit button opens dialog", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
await screen.getByRole("button", { name: "Edit Technology" }).click();
// Should open the edit dialog with "Edit Category" heading
await expect
.element(screen.getByRole("heading", { name: EDIT_CATEGORY_HEADING_REGEX }))
.toBeInTheDocument();
});
it("delete button opens confirm dialog", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
await screen.getByRole("button", { name: "Delete Technology" }).click();
// Should open a ConfirmDialog (not window.confirm)
await expect
.element(screen.getByRole("heading", { name: DELETE_CATEGORY_HEADING_REGEX }))
.toBeInTheDocument();
await expect.element(screen.getByText(DELETE_TECHNOLOGY_DESC_REGEX)).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("shows empty state when no terms", async () => {
mockApiFetch(JSON.stringify({ data: { terms: [] } }));
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await expect.element(screen.getByText(NO_CATEGORIES_REGEX)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import { describe, it, expect, beforeEach } from "vitest";
import { ThemeProvider } from "../../src/components/ThemeProvider";
import { ThemeToggle } from "../../src/components/ThemeToggle";
import { render } from "../utils/render.tsx";
function TestThemeToggle({ defaultTheme = "system" as "system" | "light" | "dark" }) {
return (
<ThemeProvider defaultTheme={defaultTheme}>
<ThemeToggle />
</ThemeProvider>
);
}
describe("ThemeToggle", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-theme");
});
it("renders with system theme by default", async () => {
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await expect.element(button).toBeInTheDocument();
// System theme shows Monitor icon - check the title
await expect.element(button).toHaveAttribute("title", expect.stringContaining("System"));
});
it("cycles from system to light on click", async () => {
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await button.click();
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Light"));
});
it("cycles through system -> light -> dark -> system", async () => {
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
// Start: system
await expect.element(button).toHaveAttribute("title", expect.stringContaining("System"));
// Click 1: light
await button.click();
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Light"));
// Click 2: dark
await button.click();
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Dark"));
// Click 3: back to system
await button.click();
await expect.element(button).toHaveAttribute("title", expect.stringContaining("System"));
});
it("persists theme to localStorage", async () => {
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await button.click(); // system -> light
expect(localStorage.getItem("emdash-theme")).toBe("light");
});
it("starts with light theme when defaultTheme is light", async () => {
const screen = await render(<TestThemeToggle defaultTheme="light" />);
const button = screen.getByRole("button");
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Light"));
});
});

View File

@@ -0,0 +1,127 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { WelcomeModal } from "../../src/components/WelcomeModal";
import { render } from "../utils/render";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WELCOME_MESSAGE_REGEX = /Welcome to EmDash, Alice!/;
const ADMIN_TEXT_REGEX = /As an administrator, you can invite other users/;
vi.mock("../../src/lib/api/client", async () => {
const actual = await vi.importActual("../../src/lib/api/client");
return {
...actual,
apiFetch: vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })),
};
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
const noop = () => {};
describe("WelcomeModal", () => {
it("shows 'Administrator' for role >= 50", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={50} />
</QueryWrapper>,
);
await expect.element(screen.getByText("Administrator", { exact: true })).toBeInTheDocument();
});
it("shows 'Editor' for role >= 40", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={40} />
</QueryWrapper>,
);
await expect.element(screen.getByText("Editor")).toBeInTheDocument();
});
it("shows 'Author' for role >= 30", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={30} />
</QueryWrapper>,
);
await expect.element(screen.getByText("Author")).toBeInTheDocument();
});
it("shows 'Contributor' for role >= 20", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={20} />
</QueryWrapper>,
);
await expect.element(screen.getByText("Contributor")).toBeInTheDocument();
});
it("shows 'Subscriber' for role < 20", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={10} />
</QueryWrapper>,
);
await expect.element(screen.getByText("Subscriber")).toBeInTheDocument();
});
it("shows first name from userName", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userName="Alice Smith" userRole={30} />
</QueryWrapper>,
);
await expect.element(screen.getByText(WELCOME_MESSAGE_REGEX)).toBeInTheDocument();
});
it("'Get Started' button triggers dismiss mutation and calls onClose", async () => {
const onClose = vi.fn();
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={onClose} userRole={30} />
</QueryWrapper>,
);
const button = screen.getByText("Get Started").element().closest("button")!;
button.click();
// The mutation resolves and calls onClose
await vi.waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it("admin text shown only for role >= 50", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={50} />
</QueryWrapper>,
);
await expect.element(screen.getByText(ADMIN_TEXT_REGEX)).toBeInTheDocument();
});
it("admin text not shown for role < 50", async () => {
const screen = await render(
<QueryWrapper>
<WelcomeModal open={true} onClose={noop} userRole={40} />
</QueryWrapper>,
);
// Check that the admin invite text is NOT present
expect(screen.container.textContent).not.toContain(
"As an administrator, you can invite other users",
);
});
});

View File

@@ -0,0 +1,213 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Widgets } from "../../src/components/Widgets";
import { render } from "../utils/render";
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchWidgetAreas: vi.fn(),
fetchWidgetComponents: vi.fn(),
fetchMenus: vi.fn().mockResolvedValue([]),
createWidgetArea: vi.fn().mockResolvedValue({}),
deleteWidgetArea: vi.fn().mockResolvedValue(undefined),
deleteWidget: vi.fn().mockResolvedValue(undefined),
updateWidget: vi.fn().mockResolvedValue({}),
reorderWidgets: vi.fn().mockResolvedValue(undefined),
};
});
import * as api from "../../src/lib/api";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DELETE_WIDGET_AREA_MSG_REGEX = /This will delete the widget area and all its widgets/;
const ADD_WIDGET_AREA_REGEX = /Add Widget Area/;
function mockDefaults() {
vi.mocked(api.fetchWidgetAreas).mockResolvedValue([
{
id: "a1",
name: "sidebar",
label: "Sidebar",
description: "Main sidebar",
widgets: [
{ id: "w1", type: "content", title: "Recent Posts", sort_order: 0 },
{ id: "w2", type: "menu", title: "Quick Links", sort_order: 1 },
],
},
]);
vi.mocked(api.fetchWidgetComponents).mockResolvedValue([
{
id: "recent-posts",
label: "Recent Posts Widget",
description: "Shows recent posts",
props: {},
},
]);
}
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<Toasty>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</Toasty>
);
}
describe("Widgets", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDefaults();
});
it("displays widget areas with labels", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Sidebar" })).toBeInTheDocument();
await expect.element(screen.getByText("Main sidebar")).toBeInTheDocument();
});
it("shows widgets within each area", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
// Widget titles are rendered in <span> elements. Use exact match to avoid
// matching the "Recent Posts Widget" component in the available widgets list.
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
// Verify widget type badges
await expect.element(screen.getByText("(content)")).toBeInTheDocument();
await expect.element(screen.getByText("(menu)")).toBeInTheDocument();
});
it("create area button opens dialog with name/label/description form", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
await screen.getByRole("button", { name: ADD_WIDGET_AREA_REGEX }).click();
await expect
.element(screen.getByRole("heading", { name: "Create Widget Area" }))
.toBeInTheDocument();
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Description")).toBeInTheDocument();
});
it("delete area shows confirmation dialog", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
await expect.element(screen.getByRole("heading", { name: "Sidebar" })).toBeInTheDocument();
// The area header has a delete button next to the area label.
// The WidgetAreaPanel header is a .p-4.border-b div with the label and a button.
// Find the button inside the panel header (the div that contains the heading "Sidebar")
const sidebarHeading = document.querySelector("h3");
expect(sidebarHeading).not.toBeNull();
// The delete button is a sibling of the div containing h3, within the .border-b parent
const headerContainer = sidebarHeading!.closest(".border-b");
expect(headerContainer).not.toBeNull();
const deleteBtn = headerContainer!.querySelector("button");
expect(deleteBtn).not.toBeNull();
(deleteBtn as HTMLButtonElement).click();
await expect
.element(screen.getByRole("heading", { name: "Delete Widget Area?" }))
.toBeInTheDocument();
await expect.element(screen.getByText(DELETE_WIDGET_AREA_MSG_REGEX)).toBeInTheDocument();
});
it("widget expand/collapse toggles editor form", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
// Initially collapsed — editor form should not be visible
expect(screen.getByText("Save").query()).toBeNull();
// Click the widget title area to expand (it's a <button> wrapping the title)
const expandButtons = document.querySelectorAll("button.text-start");
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
(expandButtons[0] as HTMLButtonElement).click();
// Now the editor should be visible with a Title field and Save button
await expect.element(screen.getByLabelText("Title")).toBeInTheDocument();
await expect.element(screen.getByText("Save")).toBeInTheDocument();
});
it("content widget editor shows portable text editor", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
// Wait for widget type badge to render — indicates widgets are loaded
await expect.element(screen.getByText("(content)")).toBeInTheDocument();
// Expand the first widget (content type — "Recent Posts")
const expandButtons = document.querySelectorAll("button.text-start");
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
(expandButtons[0] as HTMLButtonElement).click();
// Content widget should show the Save button and Title input in the editor
await expect.element(screen.getByText("Save")).toBeInTheDocument();
});
it("menu widget editor shows menu select", async () => {
vi.mocked(api.fetchMenus).mockResolvedValue([
{
id: "m1",
name: "main-nav",
label: "Main Navigation",
itemCount: 3,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
{
id: "m2",
name: "footer",
label: "Footer Menu",
itemCount: 2,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
]);
const screen = await render(<Widgets />, { wrapper: Wrapper });
// Expand the second widget (menu type — "Quick Links")
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
const expandButtons = document.querySelectorAll("button.text-start");
expect(expandButtons.length).toBeGreaterThanOrEqual(2);
(expandButtons[1] as HTMLButtonElement).click();
// Menu widget should show the Menu select
await expect.element(screen.getByLabelText("Menu")).toBeInTheDocument();
});
it("empty state when no widget areas", async () => {
vi.mocked(api.fetchWidgetAreas).mockResolvedValue([]);
const screen = await render(<Widgets />, { wrapper: Wrapper });
await expect
.element(screen.getByText("No widget areas yet. Create one to get started."))
.toBeInTheDocument();
});
it("shows available widget components panel", async () => {
const screen = await render(<Widgets />, { wrapper: Wrapper });
await expect
.element(screen.getByRole("heading", { name: "Available Widgets" }))
.toBeInTheDocument();
await expect.element(screen.getByText("Content Block")).toBeInTheDocument();
// Use exact text to avoid matching the widget type "(menu)" badge
await expect.element(screen.getByText("Display a navigation menu")).toBeInTheDocument();
await expect.element(screen.getByText("Shows recent posts")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,204 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AllowedDomainsSettings } from "../../../src/components/settings/AllowedDomainsSettings";
import { render } from "../../utils/render";
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
};
});
const EXTERNAL_PROVIDER_MSG_REGEX = /User access is managed by an external provider/;
const NO_DOMAINS_CONFIGURED_REGEX = /No domains configured/;
const mockFetchManifest = vi.fn();
const mockFetchAllowedDomains = vi.fn();
const mockCreateAllowedDomain = vi.fn();
const mockUpdateAllowedDomain = vi.fn();
const mockDeleteAllowedDomain = vi.fn();
vi.mock("../../../src/lib/api", async () => {
const actual = await vi.importActual("../../../src/lib/api");
return {
...actual,
fetchManifest: (...args: unknown[]) => mockFetchManifest(...args),
fetchAllowedDomains: (...args: unknown[]) => mockFetchAllowedDomains(...args),
createAllowedDomain: (...args: unknown[]) => mockCreateAllowedDomain(...args),
updateAllowedDomain: (...args: unknown[]) => mockUpdateAllowedDomain(...args),
deleteAllowedDomain: (...args: unknown[]) => mockDeleteAllowedDomain(...args),
};
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchManifest.mockResolvedValue({
authMode: "passkey",
collections: {},
plugins: {},
version: "1",
hash: "",
});
mockFetchAllowedDomains.mockResolvedValue([]);
mockCreateAllowedDomain.mockResolvedValue({});
mockUpdateAllowedDomain.mockResolvedValue({});
mockDeleteAllowedDomain.mockResolvedValue({});
});
describe("AllowedDomainsSettings", () => {
it("shows domain management when authMode is passkey", async () => {
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
await expect.element(screen.getByText("Self-Signup Domains")).toBeInTheDocument();
await expect.element(screen.getByText("Allowed Domains")).toBeInTheDocument();
});
it("shows info message when authMode is not passkey", async () => {
mockFetchManifest.mockResolvedValue({
authMode: "cloudflare-access",
collections: {},
plugins: {},
version: "1",
hash: "",
});
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
await expect.element(screen.getByText(EXTERNAL_PROVIDER_MSG_REGEX)).toBeInTheDocument();
});
it("empty state shows 'No domains configured'", async () => {
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
await expect.element(screen.getByText(NO_DOMAINS_CONFIGURED_REGEX)).toBeInTheDocument();
});
it("add domain form: toggles open, has domain input and role select", async () => {
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
// Wait for data to load
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
// Click Add Domain to show the form
const addButton = screen.getByText("Add Domain").element().closest("button")!;
await userEvent.click(addButton);
await expect.element(screen.getByLabelText("Domain")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Default Role")).toBeInTheDocument();
});
it("add domain: submitting calls createAllowedDomain", async () => {
mockCreateAllowedDomain.mockResolvedValue({
domain: "example.com",
defaultRole: 30,
roleName: "Author",
enabled: true,
createdAt: "2025-01-01T00:00:00Z",
});
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
// Open add form
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
const addButton = screen.getByText("Add Domain").element().closest("button")!;
await userEvent.click(addButton);
// Fill in domain
const domainInput = screen.getByLabelText("Domain");
await userEvent.type(domainInput, "example.com");
// The form submit button also says "Add Domain" — click it
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
const submitButton = screen.getByText("Add Domain").element().closest("button")!;
await userEvent.click(submitButton);
await vi.waitFor(() => {
expect(mockCreateAllowedDomain).toHaveBeenCalled();
});
expect(mockCreateAllowedDomain.mock.calls[0]![0]).toEqual({
domain: "example.com",
defaultRole: 30,
});
});
it("delete domain: confirmation dialog, confirm calls deleteAllowedDomain", async () => {
mockFetchAllowedDomains.mockResolvedValue([
{
domain: "test.com",
defaultRole: 30,
roleName: "Author",
enabled: true,
createdAt: "2025-01-01T00:00:00Z",
},
]);
mockDeleteAllowedDomain.mockResolvedValue({});
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
// Wait for the domain to appear
await expect.element(screen.getByText("test.com")).toBeInTheDocument();
// Click delete button
const deleteButton = screen.getByLabelText("Delete test.com");
await deleteButton.click();
// Confirmation dialog should appear
await expect.element(screen.getByText("Remove Domain?")).toBeInTheDocument();
// Confirm deletion - Base UI overlays block pointer events, so click the element directly
const confirmButton = screen
.getByRole("button", { name: "Remove Domain" })
.element() as HTMLButtonElement;
confirmButton.click();
await vi.waitFor(() => {
expect(mockDeleteAllowedDomain).toHaveBeenCalled();
});
expect(mockDeleteAllowedDomain.mock.calls[0]![0]).toBe("test.com");
});
it("toggle enable/disable calls updateAllowedDomain", async () => {
mockFetchAllowedDomains.mockResolvedValue([
{
domain: "test.com",
defaultRole: 30,
roleName: "Author",
enabled: true,
createdAt: "2025-01-01T00:00:00Z",
},
]);
mockUpdateAllowedDomain.mockResolvedValue({});
const screen = await render(
<QueryWrapper>
<AllowedDomainsSettings />
</QueryWrapper>,
);
// Wait for the domain to appear
await expect.element(screen.getByText("test.com")).toBeInTheDocument();
// Find and click the switch toggle
const switchEl = screen.getByRole("switch");
await switchEl.click();
expect(mockUpdateAllowedDomain).toHaveBeenCalledWith("test.com", { enabled: false });
});
});

View File

@@ -0,0 +1,151 @@
/**
* Regression test for https://github.com/emdash-cms/emdash/issues/845
*
* Several admin forms use `<input pattern="...">` for slug-style identifiers.
* Modern browsers compile that attribute as a regex with the `v`
* (unicode-sets) flag, where unescaped/dangling `-` inside a character class
* is a syntax error. The original `[a-z0-9-]+` therefore failed with
* `Invalid character class` and disabled HTML form validation entirely on
* the affected inputs.
*
* This test asserts that every slug-style `pattern` attribute in the admin
* is a valid `v`-flag regex.
*/
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Router mock (shared across components under test)
// ---------------------------------------------------------------------------
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
Link: ({
children,
to,
...props
}: {
children: React.ReactNode;
to?: string;
[key: string]: unknown;
}) => (
<a href={typeof to === "string" ? to : "#"} {...props}>
{children}
</a>
),
useNavigate: () => vi.fn(),
useParams: () => ({}),
useSearch: () => ({}),
};
});
// ---------------------------------------------------------------------------
// API mocks
// ---------------------------------------------------------------------------
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchSections: vi.fn().mockResolvedValue({ items: [] }),
createSection: vi.fn(),
deleteSection: vi.fn(),
fetchMenus: vi.fn().mockResolvedValue([]),
createMenu: vi.fn(),
deleteMenu: vi.fn(),
fetchWidgetAreas: vi.fn().mockResolvedValue([]),
fetchWidgetComponents: vi.fn().mockResolvedValue([]),
createWidgetArea: vi.fn(),
deleteWidgetArea: vi.fn(),
updateWidget: vi.fn(),
deleteWidget: vi.fn(),
reorderWidgets: vi.fn(),
};
});
// Imported lazily so the mocks above are in place
const { Sections } = await import("../../src/components/Sections");
const { MenuList } = await import("../../src/components/MenuList");
const { Widgets } = await import("../../src/components/Widgets");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<Toasty>{children}</Toasty>
</QueryClientProvider>
);
}
/**
* Asserts that every `<input pattern="...">` currently in the document is a
* valid regex when compiled with the `v` flag — which is what the browser
* does for HTML form validation.
*/
function expectAllPatternsValidV() {
const inputs = [...document.querySelectorAll<HTMLInputElement>("input[pattern]")];
expect(inputs.length).toBeGreaterThan(0);
for (const input of inputs) {
const pattern = input.getAttribute("pattern");
expect(pattern, "input pattern attribute should be set").toBeTruthy();
expect(
() => new RegExp(pattern as string, "v"),
`pattern ${JSON.stringify(pattern)} on input name=${JSON.stringify(input.name)} must compile with the 'v' flag`,
).not.toThrow();
}
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("slug pattern attributes are valid v-flag regexes (issue #845)", () => {
it("Sections create dialog slug input pattern is valid", async () => {
const screen = await render(
<Wrapper>
<Sections />
</Wrapper>,
);
await screen.getByText("New Section").click();
// Wait for the dialog to render
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
expectAllPatternsValidV();
});
it("MenuList create dialog name input pattern is valid", async () => {
const screen = await render(
<Wrapper>
<MenuList />
</Wrapper>,
);
// "Create Menu" button opens the create dialog. With empty menus, two
// buttons render (header trigger + empty state CTA). Click the first.
await screen.getByText("Create Menu").first().click();
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
expectAllPatternsValidV();
});
it("Widgets create-area dialog name input pattern is valid", async () => {
const screen = await render(
<Wrapper>
<Widgets />
</Wrapper>,
);
await screen.getByText("Add Widget Area").click();
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
expectAllPatternsValidV();
});
});

View File

@@ -0,0 +1,85 @@
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { InviteUserModal } from "../../../src/components/users/InviteUserModal";
import { render } from "../../utils/render";
const noop = () => {};
describe("InviteUserModal", () => {
it("shows email input and role select when open", async () => {
const screen = await render(
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
);
await expect.element(screen.getByLabelText("Email address")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Role")).toBeInTheDocument();
});
it("default role is Author (30)", async () => {
const screen = await render(
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
);
// The select should display "Author" as the selected value
await expect.element(screen.getByText("Author")).toBeInTheDocument();
});
it("submit calls onInvite with email and role", async () => {
const onInvite = vi.fn();
const screen = await render(
<InviteUserModal open={true} onOpenChange={noop} onInvite={onInvite} />,
);
const emailInput = screen.getByLabelText("Email address");
await userEvent.type(emailInput, "new@example.com");
const submitButton = screen.getByText("Send Invite").element().closest("button")!;
submitButton.click();
expect(onInvite).toHaveBeenCalledWith("new@example.com", 30);
});
it("submit button disabled when email is empty", async () => {
const screen = await render(
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
);
const submitButton = screen.getByText("Send Invite").element().closest("button")!;
expect(submitButton.disabled).toBe(true);
});
it("submit button disabled when isSending", async () => {
const screen = await render(
<InviteUserModal open={true} isSending={true} onOpenChange={noop} onInvite={noop} />,
);
const submitButton = screen.getByText("Sending...").element().closest("button")!;
expect(submitButton.disabled).toBe(true);
});
it("shows error message when error prop provided", async () => {
const screen = await render(
<InviteUserModal
open={true}
error="Email already exists"
onOpenChange={noop}
onInvite={noop}
/>,
);
await expect.element(screen.getByText("Email already exists")).toBeInTheDocument();
});
it("form resets when modal opens", async () => {
const result = await render(
<InviteUserModal open={false} onOpenChange={noop} onInvite={noop} />,
);
// Open the modal - the effect should reset email to "" and role to 30
await result.rerender(<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />);
await expect.element(result.getByLabelText("Email address")).toHaveValue("");
});
it("cancel button closes modal", async () => {
const onOpenChange = vi.fn();
const screen = await render(
<InviteUserModal open={true} onOpenChange={onOpenChange} onInvite={noop} />,
);
const cancelButton = screen.getByText("Cancel").element().closest("button")!;
cancelButton.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,304 @@
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { UserDetail } from "../../../src/components/users/UserDetail";
import type { UserDetail as UserDetailType } from "../../../src/lib/api";
import { render } from "../../utils/render";
function makeUser(overrides: Partial<UserDetailType> = {}): UserDetailType {
return {
id: "user-1",
email: "test@example.com",
name: "Test User",
avatarUrl: null,
role: 30,
emailVerified: true,
disabled: false,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
lastLogin: "2025-01-02T00:00:00Z",
credentialCount: 1,
oauthProviders: [],
credentials: [
{
id: "cred-1",
name: "My Passkey",
deviceType: "multiDevice",
createdAt: "2025-01-01T00:00:00Z",
lastUsedAt: "2025-01-02T00:00:00Z",
},
],
oauthAccounts: [],
...overrides,
};
}
const noop = () => {};
describe("UserDetail", () => {
it("hides dialog when not open", async () => {
await render(
<UserDetail
user={makeUser()}
isOpen={false}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Dialog.Portal renders outside the container; the popup should be hidden
const popup = document.querySelector("[role='dialog']") as HTMLElement;
expect(popup?.hidden ?? true).toBe(true);
});
it("shows loading skeleton when isLoading", async () => {
const screen = await render(
<UserDetail
user={null}
isLoading={true}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Skeleton has the animate-pulse class; portal renders outside screen.container
await expect.element(screen.getByRole("dialog")).toBeInTheDocument();
expect(document.querySelector(".animate-pulse")).not.toBeNull();
});
it("shows 'User not found' when not loading and no user", async () => {
const screen = await render(
<UserDetail
user={null}
isLoading={false}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
await expect.element(screen.getByText("User not found")).toBeInTheDocument();
});
it("displays user name, email, and role correctly", async () => {
const user = makeUser({ name: "Alice Smith", email: "alice@example.com", role: 40 });
const screen = await render(
<UserDetail
user={user}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Name input
await expect.element(screen.getByLabelText("Name")).toHaveValue("Alice Smith");
// Email input
await expect.element(screen.getByLabelText("Email")).toHaveValue("alice@example.com");
});
it("escape key calls onClose", async () => {
const onClose = vi.fn();
await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={onClose}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("backdrop click calls onClose", async () => {
const onClose = vi.fn();
await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={onClose}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Dialog.Portal renders outside screen.container
const backdrop = document.querySelector("[role='presentation']") as HTMLElement;
expect(backdrop).not.toBeNull();
backdrop.click();
expect(onClose).toHaveBeenCalled();
});
it("save button disabled when no changes", async () => {
const screen = await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
await expect.element(screen.getByText("Save Changes")).toBeInTheDocument();
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
expect(saveButton.disabled).toBe(true);
});
it("changing name enables save", async () => {
const screen = await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "New Name");
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
expect(saveButton.disabled).toBe(false);
});
it("changing email enables save", async () => {
const screen = await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
const emailInput = screen.getByLabelText("Email");
await userEvent.clear(emailInput);
await userEvent.type(emailInput, "new@example.com");
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
expect(saveButton.disabled).toBe(false);
});
it("onSave only includes changed fields", async () => {
const onSave = vi.fn();
const screen = await render(
<UserDetail
user={makeUser({ name: "Original" })}
isOpen={true}
onClose={noop}
onSave={onSave}
onDisable={noop}
onEnable={noop}
/>,
);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Changed");
// Submit the form -- use native click to bypass data-base-ui-inert overlay
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
saveButton.click();
expect(onSave).toHaveBeenCalledWith({ name: "Changed" });
});
it("self-user: role selector is disabled", async () => {
const user = makeUser({ id: "me" });
const screen = await render(
<UserDetail
user={user}
isOpen={true}
currentUserId="me"
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
await expect.element(screen.getByText("You cannot change your own role")).toBeInTheDocument();
});
it("self-user: disable button not shown", async () => {
const user = makeUser({ id: "me" });
await render(
<UserDetail
user={user}
isOpen={true}
currentUserId="me"
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Dialog.Portal renders outside screen.container; query the dialog popup directly
const dialog = document.querySelector("[role='dialog']")!;
const buttons = dialog.querySelectorAll("button");
const disableButton = [...buttons].find((b) => b.textContent?.includes("Disable"));
expect(disableButton).toBeUndefined();
});
it("non-self: disable button shown and calls onDisable", async () => {
const onDisable = vi.fn();
const screen = await render(
<UserDetail
user={makeUser({ id: "other" })}
isOpen={true}
currentUserId="me"
onClose={noop}
onSave={noop}
onDisable={onDisable}
onEnable={noop}
/>,
);
// Use native click to bypass data-base-ui-inert overlay
const disableButton = screen.getByText("Disable").element().closest("button")!;
disableButton.click();
expect(onDisable).toHaveBeenCalled();
});
it("enable button shown for disabled users and calls onEnable", async () => {
const onEnable = vi.fn();
const screen = await render(
<UserDetail
user={makeUser({ id: "other", disabled: true })}
isOpen={true}
currentUserId="me"
onClose={noop}
onSave={noop}
onDisable={noop}
onEnable={onEnable}
/>,
);
// Use native click to bypass data-base-ui-inert overlay
const enableButton = screen.getByText("Enable").element().closest("button")!;
enableButton.click();
expect(onEnable).toHaveBeenCalled();
});
it("close button calls onClose", async () => {
const onClose = vi.fn();
const screen = await render(
<UserDetail
user={makeUser()}
isOpen={true}
onClose={onClose}
onSave={noop}
onDisable={noop}
onEnable={noop}
/>,
);
// Use native click to bypass data-base-ui-inert overlay
screen.getByLabelText("Close panel").element().click();
expect(onClose).toHaveBeenCalled();
});
});