import "dotenv/config"; import { expect, test } from "@playwright/test"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin"; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; test.describe.configure({ mode: "serial" }); test("anonymous visitors are redirected away from admin routes", async ({ page }) => { await page.goto("/admin"); await expect(page).toHaveURL(/\/admin\/login/); await page.goto("/admin/posts/new"); await expect(page).toHaveURL(/\/admin\/login/); }); test("login rejects bad credentials", async ({ page }) => { await page.goto("/admin/login"); await page.getByLabel("Username").fill(ADMIN_USERNAME); await page.getByLabel("Password").fill("definitely-the-wrong-password"); await page.getByRole("button", { name: "Sign in" }).click(); // Next.js's route announcer is also role=alert, so match on the text. await expect(page.getByText("Invalid username or password.")).toBeVisible(); await expect(page).toHaveURL(/\/admin\/login/); }); test("full editorial flow: login → create → publish → public view → logout", async ({ page, }) => { const title = "Publishing pipeline smoke test"; const slug = "publishing-pipeline-smoke-test"; // --- Login --------------------------------------------------------------- await page.goto("/admin/login"); await page.getByLabel("Username").fill(ADMIN_USERNAME); await page.getByLabel("Password").fill(ADMIN_PASSWORD); await page.getByRole("button", { name: "Sign in" }).click(); await expect(page).toHaveURL(/\/admin$/); // --- Create a post in the rich text editor ------------------------------- // The editor opens on the Content tab; metadata lives on Post settings. await page.goto("/admin/posts/new"); const editor = page.locator(".tiptap"); await editor.click(); await page.getByRole("button", { name: "Heading level 2" }).click(); await page.keyboard.type("Why this works"); await page.keyboard.press("Enter"); await page.getByRole("button", { name: "Bold", exact: true }).click(); await page.keyboard.type("Bold statement"); await page.getByRole("button", { name: "Bold", exact: true }).click(); await page.keyboard.type(" and plain text follow."); // WYSIWYG: the editor itself shows the structure it will publish. await expect(editor.locator("h2")).toContainText("Why this works"); await expect(editor.locator("strong")).toContainText("Bold statement"); await page.getByRole("tab", { name: "Post settings" }).click(); await page.getByLabel("Title").fill(title); await expect(page.getByLabel("Slug")).toHaveValue(slug); // auto-generated await page.getByLabel("Design").check(); // existing seeded tag await page.getByLabel("New tags (optional)").fill("E2E Coverage"); // Switching back to Content must not lose the composed body. await page.getByRole("tab", { name: "Content" }).click(); await expect(editor.locator("h2")).toContainText("Why this works"); // Status + save live in the always-visible action bar. await page.getByLabel("Status").selectOption("published"); await page.getByRole("button", { name: "Save post" }).click(); await expect(page).toHaveURL(/\/admin\/posts\/\d+\/edit\?saved=1/); await expect(page.getByText("Saved.", { exact: true })).toBeVisible(); // --- Public listing shows it; seeded drafts stay hidden ------------------- await page.goto("/posts"); await expect(page.getByRole("link", { name: title })).toBeVisible(); await expect(page.getByText("Secret draft (should never be public)")).toHaveCount(0); // --- Full post page: rich content rendered as authored ------------------- await page.getByRole("link", { name: title }).click(); await expect(page).toHaveURL(new RegExp(`/posts/${slug}$`)); await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible(); await expect( page.locator(".markdown-body").getByRole("heading", { level: 2, name: "Why this works" }), ).toBeVisible(); await expect(page.locator(".markdown-body strong")).toContainText("Bold statement"); // --- The new tag is in the sidebar and filters correctly ------------------ const tagNav = page.getByRole("navigation", { name: "Posts by tag" }); await expect(tagNav.getByRole("link", { name: /E2E Coverage/ })).toBeVisible(); // The seeded draft-only tag must not be listed publicly. await expect(tagNav.getByRole("link", { name: /Secrets/ })).toHaveCount(0); await tagNav.getByRole("link", { name: /E2E Coverage/ }).click(); await expect(page).toHaveURL(/\/tags\/e2e-coverage/); await expect(page.getByRole("link", { name: title })).toBeVisible(); // --- Draft posts 404 on the public site ---------------------------------- const draftResponse = await page.goto("/posts/secret-draft"); expect(draftResponse?.status()).toBe(404); // --- Logout locks the admin again ----------------------------------------- await page.goto("/admin"); await page.getByRole("button", { name: "Log out" }).click(); await expect(page).toHaveURL(/\/admin\/login/); await page.goto("/admin/posts"); await expect(page).toHaveURL(/\/admin\/login/); }); test("rich editor: markdown paste, XSS stripping, and inline image upload", async ({ page, request, }) => { const PNG_FIXTURE = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64", ); await page.goto("/admin/login"); await page.getByLabel("Username").fill(ADMIN_USERNAME); await page.getByLabel("Password").fill(ADMIN_PASSWORD); await page.getByRole("button", { name: "Sign in" }).click(); await expect(page).toHaveURL(/\/admin$/); await page.goto("/admin/posts/new"); await page.getByRole("tab", { name: "Post settings" }).click(); await page.getByLabel("Title").fill("Editor capabilities check"); await page.getByRole("tab", { name: "Content" }).click(); const editor = page.locator(".tiptap"); await editor.click(); // Pasting plain-text Markdown converts it — including stripping an // embedded ", ); document .querySelector(".tiptap")! .dispatchEvent( new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }), ); }); await expect(editor.getByRole("heading", { level: 2 })).toContainText("Pasted heading"); await expect(editor.locator("strong")).toContainText("pasted bold"); expect(await editor.innerHTML()).not.toContain("alert(1)"); // Upload an image through the toolbar's file input; it lands inline. await page .getByTestId("editor-image-input") .setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: PNG_FIXTURE }); await expect(editor.locator('img[src^="/uploads/"]')).toBeVisible(); const uploadedSrc = await editor.locator('img[src^="/uploads/"]').getAttribute("src"); // Publish and verify the public page renders everything. await page.getByLabel("Status").selectOption("published"); await page.getByRole("button", { name: "Save post" }).click(); await expect(page).toHaveURL(/\/admin\/posts\/\d+\/edit\?saved=1/); await page.goto("/posts/editor-capabilities-check"); await expect( page.locator(".markdown-body").getByRole("heading", { level: 2, name: "Pasted heading" }), ).toBeVisible(); await expect(page.locator(".markdown-body strong")).toContainText("pasted bold"); const publicBody = await page.locator(".markdown-body").innerHTML(); expect(publicBody).not.toContain(" { // Seeded defaults. await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark"); await expect(page.locator("html")).toHaveAttribute("data-font", "geist"); await page.goto("/admin/login"); await page.getByLabel("Username").fill(ADMIN_USERNAME); await page.getByLabel("Password").fill(ADMIN_PASSWORD); await page.getByRole("button", { name: "Sign in" }).click(); await expect(page).toHaveURL(/\/admin$/); // Switch to Dracula + Lora. await page.goto("/admin/settings"); await page.getByRole("radio", { name: "Dracula" }).check(); await page.getByRole("radio", { name: "Lora" }).check(); await page.getByRole("button", { name: "Save settings" }).click(); await expect(page.getByText("Settings saved.")).toBeVisible(); // Public site picks up both; body background is Dracula's #282a36 and // the computed font stack leads with Lora. await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula"); await expect(page.locator("html")).toHaveAttribute("data-font", "lora"); await expect(page.locator("body")).toHaveCSS("background-color", "rgb(40, 42, 54)"); await expect(page.locator("body")).toHaveCSS("font-family", /Lora/); // The admin area follows the same appearance settings. await page.goto("/admin/settings"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula"); // Black & White renders plain white. await page.getByRole("radio", { name: "Black & White" }).check(); await page.getByRole("button", { name: "Save settings" }).click(); await expect(page.getByText("Settings saved.")).toBeVisible(); await page.goto("/"); await expect(page.locator("body")).toHaveCSS("background-color", "rgb(255, 255, 255)"); // One of the newer palettes + fonts: Tokyo Night with Space Grotesk. await page.goto("/admin/settings"); await page.getByRole("radio", { name: "Tokyo Night" }).check(); await page.getByRole("radio", { name: "Space Grotesk" }).check(); await page.getByRole("button", { name: "Save settings" }).click(); await expect(page.getByText("Settings saved.")).toBeVisible(); await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme", "tokyo-night"); await expect(page.locator("body")).toHaveCSS("background-color", "rgb(26, 27, 38)"); await expect(page.locator("body")).toHaveCSS("font-family", /Space Grotesk/); // And back to the seeded defaults. await page.goto("/admin/settings"); await page.getByRole("radio", { name: "Solarized Dark" }).check(); await page.getByRole("radio", { name: /^Geist/ }).check(); await page.getByRole("button", { name: "Save settings" }).click(); await expect(page.getByText("Settings saved.")).toBeVisible(); await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark"); await expect(page.locator("html")).toHaveAttribute("data-font", "geist"); await expect(page.locator("body")).toHaveCSS("background-color", "rgb(0, 43, 54)"); });