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("