/** * Visual Editing / Inline Editor E2E Tests * * Tests the inline TipTap editor for portable text fields: * - Image rendering in static mode (Image.astro) * - Inline editor loading with image nodes * - Slash commands and media picker * - Save on image insert */ import { test, expect } from "../fixtures"; const MEDIA_FILE_PATTERN = /\/_emdash\/api\/media\/file\/.+\.\w+/; const IMAGE_BUTTON_PATTERN = /Image/; // The seeded post with an image block has slug "post-with-image" const POST_WITH_IMAGE_PATH = "/posts/post-with-image"; /** * Navigate to a page, retrying if Astro's dev server shows a compilation error. * This handles the transient "No cached compile metadata" race condition. */ async function gotoWithRetry( page: import("@playwright/test").Page, url: string, maxRetries = 3, ): Promise { for (let i = 0; i < maxRetries; i++) { await page.goto(url); await page.waitForLoadState("domcontentloaded"); // Check if the page has an Astro error overlay const hasError = await page.locator("text=An error occurred").count(); if (hasError === 0) return; // Wait and retry — Astro needs time to compile virtual modules await page.waitForTimeout(2000); } } /** * Enable visual editing mode by setting the edit-mode cookie * and authenticating via dev bypass. */ async function enableEditMode(page: import("@playwright/test").Page): Promise { // Authenticate first (sets session cookie) await page.goto("/_emdash/api/setup/dev-bypass?redirect=/"); await page.waitForLoadState("networkidle"); // Set the edit mode cookie await page.context().addCookies([ { name: "emdash-edit-mode", value: "true", domain: "localhost", path: "/", }, ]); } test.describe("Image Rendering (Static)", () => { test("renders image block with correct src URL", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); // The Image.astro component renders a
with an const figure = page.locator("figure.emdash-image"); await expect(figure).toBeVisible({ timeout: 10000 }); const img = figure.locator("img"); await expect(img).toBeVisible(); // The src should point to the media file endpoint (not a bare ULID) const src = await img.getAttribute("src"); expect(src).toBeTruthy(); expect(src).toMatch(MEDIA_FILE_PATTERN); // Alt text should be set const alt = await img.getAttribute("alt"); expect(alt).toBe("Test image"); }); test("renders text blocks around the image", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const body = page.locator("#body"); await expect(body).toBeVisible({ timeout: 10000 }); // Should contain the text paragraphs await expect(body.locator("text=Text before image.")).toBeVisible(); await expect(body.locator("text=Text after image.")).toBeVisible(); }); }); test.describe("Inline Editor", () => { test.beforeEach(async ({ page }) => { await enableEditMode(page); }); test("loads without crashing on posts with image blocks", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); // The inline editor renders as a .emdash-inline-editor div (TipTap's editorProps class) const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Should contain the text content (not crash with RangeError) await expect(editor.locator("text=Text before image.")).toBeVisible(); await expect(editor.locator("text=Text after image.")).toBeVisible(); // Should render the image node (TipTap Image extension) const img = editor.locator("img"); await expect(img).toBeVisible(); }); test("shows slash menu on / keystroke", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Click into the editor to focus, then type / await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/"); // Slash menu should appear const slashMenu = page.locator(".emdash-slash-menu"); await expect(slashMenu).toBeVisible({ timeout: 5000 }); // Should have the Image command — use role to avoid matching the description text await expect(slashMenu.getByRole("button", { name: IMAGE_BUTTON_PATTERN })).toBeVisible(); }); test.fixme("slash menu does not scroll page to top", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Inject extra height so the page is scrollable await page.evaluate(() => { const spacer = document.createElement("div"); spacer.style.height = "2000px"; document.body.appendChild(spacer); }); // Scroll down a bit first to have a non-zero scroll position await page.evaluate(() => window.scrollTo(0, 200)); await page.waitForTimeout(100); const scrollBefore = await page.evaluate(() => window.scrollY); expect(scrollBefore).toBeGreaterThan(0); // Focus editor and type / await editor.locator("p").last().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/"); const slashMenu = page.locator(".emdash-slash-menu"); await expect(slashMenu).toBeVisible({ timeout: 5000 }); // Scroll position should not have jumped to top const scrollAfter = await page.evaluate(() => window.scrollY); // Allow some tolerance (e.g. +-20px for natural scroll adjustments) expect(scrollAfter).toBeGreaterThan(scrollBefore - 20); }); test("/image command opens media picker", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Type /image to filter to the Image command await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/image"); const slashMenu = page.locator(".emdash-slash-menu"); await expect(slashMenu).toBeVisible({ timeout: 5000 }); // Click the Image command (or press Enter to select it) await page.keyboard.press("Enter"); // Media picker should open const picker = page.locator(".emdash-media-picker"); await expect(picker).toBeVisible({ timeout: 5000 }); // Should show "Insert Image" title await expect(picker.locator("text=Insert Image")).toBeVisible(); }); test("media picker shows uploaded images", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Open media picker via slash command await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/image"); await page.keyboard.press("Enter"); const picker = page.locator(".emdash-media-picker"); await expect(picker).toBeVisible({ timeout: 5000 }); // Should show at least one image (the seeded test-image.png) // Wait for loading to finish — the picker shows "Loading…" then the count await expect(picker.getByText("Loading…").first()).toBeHidden({ timeout: 10000 }); // Grid should have at least one image thumbnail const images = picker.locator("img"); const count = await images.count(); expect(count).toBeGreaterThan(0); }); test("selecting image from picker inserts it and triggers save", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Count existing images in editor const initialImageCount = await editor.locator("img").count(); // Open media picker via slash command await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/image"); await page.keyboard.press("Enter"); const picker = page.locator(".emdash-media-picker"); await expect(picker).toBeVisible({ timeout: 5000 }); // Wait for images to load await expect(picker.getByText("Loading…").first()).toBeHidden({ timeout: 10000 }); // Click the first image in the grid to select it const firstThumb = picker .locator("button") .filter({ has: page.locator("img") }) .first(); await firstThumb.click(); // Set up response listener for the save request before clicking Insert const savePromise = page.waitForResponse( (res) => res.url().includes("/api/content/posts/") && res.request().method() === "PUT", { timeout: 10000 }, ); // Click Insert button const insertButton = picker.locator("button", { hasText: "Insert" }); await expect(insertButton).toBeVisible(); await insertButton.click(); // Picker should close await expect(picker).toBeHidden({ timeout: 3000 }); // A new image should appear in the editor const newImageCount = await editor.locator("img").count(); expect(newImageCount).toBeGreaterThan(initialImageCount); // Save should have been triggered const saveResponse = await savePromise; expect(saveResponse.ok()).toBe(true); }); test("media picker can be closed with cancel", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Open media picker await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/image"); await page.keyboard.press("Enter"); const picker = page.locator(".emdash-media-picker"); await expect(picker).toBeVisible({ timeout: 5000 }); // Click Cancel const cancelButton = picker.locator("button", { hasText: "Cancel" }); await cancelButton.click(); // Picker should close await expect(picker).toBeHidden({ timeout: 3000 }); }); test("media picker can be closed with X button", async ({ page }) => { await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); // Open media picker await editor.locator("p").first().click(); await page.keyboard.press("End"); await page.keyboard.press("Enter"); await page.keyboard.type("/image"); await page.keyboard.press("Enter"); const picker = page.locator(".emdash-media-picker"); await expect(picker).toBeVisible({ timeout: 5000 }); // Click the close (X) button const closeButton = picker.locator('button[aria-label="Close"]'); await closeButton.click(); // Picker should close await expect(picker).toBeHidden({ timeout: 3000 }); }); });