b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
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({}),
|
||
deleteFromProvider: vi.fn().mockResolvedValue({}),
|
||
};
|
||
});
|
||
|
||
// Import the mocked functions for assertions
|
||
import { updateMedia, deleteMedia, deleteFromProvider } 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 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,
|
||
};
|
||
}
|
||
|
||
function renderPanel(props: Partial<React.ComponentProps<typeof MediaDetailPanel>> = {}) {
|
||
const defaultProps: React.ComponentProps<typeof MediaDetailPanel> = {
|
||
open: true,
|
||
item: makeImageItem(),
|
||
onClose: vi.fn(),
|
||
onDeleted: vi.fn(),
|
||
...props,
|
||
};
|
||
return render(
|
||
<QueryWrapper>
|
||
<MediaDetailPanel {...defaultProps} />
|
||
</QueryWrapper>,
|
||
);
|
||
}
|
||
|
||
describe("MediaDetailPanel", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it("does not render dialog contents when closed", async () => {
|
||
const screen = await renderPanel({ open: false });
|
||
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("renders the responsive two-column viewport-bounded dialog layout", async () => {
|
||
const screen = await renderPanel();
|
||
const dialog = screen.getByRole("dialog", { name: "Media Details" }).element();
|
||
const header = screen.getByTestId("media-detail-dialog-header").element();
|
||
const body = screen.getByTestId("media-detail-dialog-body").element();
|
||
const previewColumn = screen.getByTestId("media-detail-dialog-preview-column").element();
|
||
const detailsColumn = screen.getByTestId("media-detail-dialog-details-column").element();
|
||
const fileFacts = screen.getByTestId("media-detail-dialog-file-facts").element();
|
||
const footer = screen.getByTestId("media-detail-dialog-footer").element();
|
||
|
||
expect(body.className).toContain("grid-cols-1");
|
||
expect(body.className).toContain("md:grid-cols-2");
|
||
expect(dialog.style.height).toBe("");
|
||
expect(dialog.style.maxHeight).toBe("min(88dvh, 48rem)");
|
||
expect(dialog.className).toContain("data-starting-style:scale-90");
|
||
expect(dialog.className).toContain("data-starting-style:opacity-0");
|
||
expect(dialog.style.transitionProperty).toBe("scale, opacity");
|
||
expect(header.style.padding).toBe("1.25rem 2rem");
|
||
expect(previewColumn.contains(fileFacts)).toBe(true);
|
||
expect(detailsColumn.contains(fileFacts)).toBe(false);
|
||
expect(previewColumn.className).toContain("md:p-8");
|
||
expect(detailsColumn.className).toContain("md:p-8");
|
||
// Columns only constrain/scroll at md+; on mobile the body scrolls as one
|
||
// column so collapsed columns can't compress and overlap their content.
|
||
expect(body.className).toContain("overflow-y-auto");
|
||
expect(body.className).toContain("md:overflow-hidden");
|
||
expect(previewColumn.className).toContain("md:min-h-0");
|
||
expect(previewColumn.className).toContain("md:overflow-y-auto");
|
||
expect(previewColumn.className).not.toContain(" min-h-0");
|
||
expect(detailsColumn.className).toContain("md:min-h-0");
|
||
expect(detailsColumn.className).toContain("md:overflow-y-auto");
|
||
expect(detailsColumn.className).not.toContain(" min-h-0");
|
||
expect(fileFacts.className).toContain("space-y-3");
|
||
expect(footer.style.padding).toBe("1.25rem 2rem");
|
||
});
|
||
|
||
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();
|
||
expect(altInput.element().className).toContain("w-full");
|
||
await expect
|
||
.element(screen.getByRole("button", { name: "Why is this important?" }))
|
||
.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();
|
||
await expect
|
||
.element(screen.getByLabelText("Caption"), { timeout: 100 })
|
||
.not.toBeInTheDocument();
|
||
});
|
||
|
||
it("filename input is disabled with tooltip help", async () => {
|
||
const item = makeImageItem();
|
||
const screen = await renderPanel({ item });
|
||
const filenameInput = screen.getByLabelText("Filename");
|
||
await expect.element(filenameInput).toBeDisabled();
|
||
expect(filenameInput.element().className).toContain("bg-kumo-tint");
|
||
expect(filenameInput.element().className).toContain("w-full");
|
||
await expect
|
||
.element(screen.getByRole("button", { name: "Why can't this be changed?" }))
|
||
.toBeInTheDocument();
|
||
});
|
||
|
||
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 onClose = vi.fn();
|
||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||
const screen = await renderPanel({ item, onClose });
|
||
|
||
const altInput = screen.getByLabelText("Alt Text");
|
||
await altInput.fill("New alt");
|
||
|
||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||
await expect.element(saveBtn).toBeEnabled();
|
||
saveBtn.element().click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||
alt: "New alt",
|
||
caption: "Old caption",
|
||
});
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("saves empty strings when clearing alt text and caption", async () => {
|
||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||
const screen = await renderPanel({ item });
|
||
|
||
await screen.getByLabelText("Alt Text").fill("");
|
||
await screen.getByLabelText("Caption").fill("");
|
||
screen.getByRole("button", { name: "Save" }).element().click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||
alt: "",
|
||
caption: "",
|
||
});
|
||
});
|
||
});
|
||
|
||
it("disables editing and closing while a save is pending", async () => {
|
||
let resolveUpdate!: (item: MediaItem) => void;
|
||
vi.mocked(updateMedia).mockImplementationOnce(
|
||
() => new Promise<MediaItem>((resolve) => (resolveUpdate = resolve)),
|
||
);
|
||
const onClose = vi.fn();
|
||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||
const screen = await renderPanel({ item, onClose });
|
||
|
||
const altInput = screen.getByLabelText("Alt Text");
|
||
await altInput.fill("New alt");
|
||
screen.getByRole("button", { name: "Save" }).element().click();
|
||
|
||
await expect.element(altInput).toBeDisabled();
|
||
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||
await expect.element(screen.getByRole("button", { name: "Close" })).toBeDisabled();
|
||
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||
|
||
screen.getByRole("button", { name: "Close" }).element().click();
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
|
||
resolveUpdate({ ...item, alt: "New alt" });
|
||
await vi.waitFor(() => {
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("saves dirty metadata with the keyboard shortcut", async () => {
|
||
const onClose = vi.fn();
|
||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||
const screen = await renderPanel({ item, onClose });
|
||
|
||
await screen.getByLabelText("Alt Text").fill("Shortcut alt");
|
||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true }));
|
||
|
||
await vi.waitFor(() => {
|
||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||
alt: "Shortcut alt",
|
||
caption: "Old caption",
|
||
});
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("does not consume the keyboard save shortcut when nothing can be saved", async () => {
|
||
await renderPanel({
|
||
item: makeImageItem({ provider: "cloudflare-images" }),
|
||
providerName: "Cloudflare Images",
|
||
});
|
||
|
||
const event = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true });
|
||
window.dispatchEvent(event);
|
||
|
||
expect(event.defaultPrevented).toBe(false);
|
||
expect(updateMedia).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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" });
|
||
deleteBtn.element().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("deletes provider assets through the provider API when deletion is supported", async () => {
|
||
const onDeleted = vi.fn();
|
||
const screen = await renderPanel({
|
||
item: makeImageItem({ id: "provider-1", provider: "cloudflare-images" }),
|
||
providerName: "Cloudflare Images",
|
||
canDelete: true,
|
||
onDeleted,
|
||
});
|
||
|
||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||
screen.getByRole("button", { name: "Delete" }).all().at(-1)!.element().click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(deleteFromProvider).toHaveBeenCalledWith("cloudflare-images", "provider-1");
|
||
expect(deleteMedia).not.toHaveBeenCalled();
|
||
expect(onDeleted).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
it("delete cancelled does not call deleteMedia", async () => {
|
||
const item = makeImageItem();
|
||
|
||
const screen = await renderPanel({ item });
|
||
const deleteBtn = screen.getByRole("button", { name: "Delete" });
|
||
deleteBtn.element().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" }).all().at(-1)!.element().click();
|
||
|
||
expect(deleteMedia).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("close button calls onClose when clean", async () => {
|
||
const onClose = vi.fn();
|
||
const item = makeImageItem();
|
||
const screen = await renderPanel({ item, onClose });
|
||
|
||
screen.getByRole("button", { name: "Close" }).element().click();
|
||
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("close button opens discard confirmation when dirty", async () => {
|
||
const onClose = vi.fn();
|
||
const item = makeImageItem({ alt: "Original" });
|
||
const screen = await renderPanel({ item, onClose });
|
||
|
||
await screen.getByLabelText("Alt Text").fill("Changed alt");
|
||
screen.getByRole("button", { name: "Close" }).element().click();
|
||
|
||
await expect.element(screen.getByText("Discard changes?")).toBeInTheDocument();
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
|
||
screen.getByRole("button", { name: "Discard" }).element().click();
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("cancels the close fallback when reopened before the fallback timer fires", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const onClose = vi.fn();
|
||
const onClosed = vi.fn();
|
||
const firstItem = makeImageItem({ id: "media-1", filename: "first.jpg" });
|
||
const secondItem = makeImageItem({ id: "media-2", filename: "second.jpg" });
|
||
|
||
const screen = await render(
|
||
<QueryWrapper>
|
||
<MediaDetailPanel
|
||
open
|
||
item={firstItem}
|
||
onClose={onClose}
|
||
onClosed={onClosed}
|
||
onDeleted={vi.fn()}
|
||
/>
|
||
</QueryWrapper>,
|
||
);
|
||
|
||
screen.getByRole("button", { name: "Close" }).element().click();
|
||
expect(onClose).toHaveBeenCalled();
|
||
|
||
await screen.rerender(
|
||
<QueryWrapper>
|
||
<MediaDetailPanel
|
||
open
|
||
item={secondItem}
|
||
onClose={onClose}
|
||
onClosed={onClosed}
|
||
onDeleted={vi.fn()}
|
||
/>
|
||
</QueryWrapper>,
|
||
);
|
||
await vi.advanceTimersByTimeAsync(500);
|
||
|
||
expect(onClosed).not.toHaveBeenCalled();
|
||
await expect.element(screen.getByLabelText("Filename")).toHaveValue("second.jpg");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
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 open 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");
|
||
});
|
||
});
|
||
|
||
describe("MediaDetailPanel file URL", () => {
|
||
it("shows the absolute file URL with a Copy URL action", async () => {
|
||
const screen = await renderPanel({
|
||
item: makeImageItem({ url: "/_emdash/api/media/file/01ABC.jpg" }),
|
||
});
|
||
|
||
// Relative local-storage URLs are shown as absolute (origin-resolved)
|
||
// so they can be pasted anywhere. The Kumo ClipboardText component
|
||
// renders the value as text and a copy button (labelled "Copy URL").
|
||
const absolute = new URL("/_emdash/api/media/file/01ABC.jpg", window.location.origin).href;
|
||
await expect.element(screen.getByText(absolute)).toBeVisible();
|
||
await expect.element(screen.getByRole("button", { name: /Copy URL/ })).toBeVisible();
|
||
});
|
||
|
||
it("does not expose provider preview URLs as public URLs", async () => {
|
||
const screen = await renderPanel({
|
||
item: makeImageItem({ provider: "cloudflare-images", url: "https://preview.example/image" }),
|
||
providerName: "Cloudflare Images",
|
||
});
|
||
|
||
await expect.element(screen.getByText("Managed by Cloudflare Images")).toBeVisible();
|
||
await expect.element(screen.getByText("No public URL available")).toBeVisible();
|
||
await expect
|
||
.element(screen.getByRole("button", { name: /Copy URL/ }), { timeout: 100 })
|
||
.not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders provider assets read-only", async () => {
|
||
const screen = await renderPanel({
|
||
item: makeImageItem({ provider: "cloudflare-images" }),
|
||
providerName: "Cloudflare Images",
|
||
});
|
||
|
||
await expect
|
||
.element(screen.getByRole("button", { name: "Delete" }), { timeout: 100 })
|
||
.not.toBeInTheDocument();
|
||
await expect
|
||
.element(screen.getByRole("button", { name: "Save" }), { timeout: 100 })
|
||
.not.toBeInTheDocument();
|
||
await expect
|
||
.element(screen.getByLabelText("Alt Text"), { timeout: 100 })
|
||
.not.toBeInTheDocument();
|
||
await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument();
|
||
});
|
||
});
|