first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "E2E Test Fixture",
"description": "Schema for E2E tests"
},
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" },
{ "slug": "opinion", "label": "Opinion" }
]
},
{
"name": "tag",
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["posts"]
}
],
"sections": [
{
"slug": "hero",
"title": "Hero Section",
"description": "Main hero area",
"content": [
{
"_type": "block",
"_key": "b1",
"style": "normal",
"children": [{ "_type": "span", "_key": "s1", "text": "Welcome to our site" }],
"markDefs": []
}
]
}
],
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "search"],
"commentsEnabled": true,
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "featured_image",
"label": "Featured Image",
"type": "image"
},
{
"slug": "body",
"label": "Body",
"type": "portableText",
"searchable": true
},
{
"slug": "excerpt",
"label": "Excerpt",
"type": "text",
"searchable": true
},
{
"slug": "theme_color",
"label": "Theme Color",
"type": "string",
"widget": "color:picker"
}
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"supports": ["drafts", "revisions", "search"],
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "body",
"label": "Body",
"type": "portableText",
"searchable": true
}
]
}
],
"bylines": [
{
"id": "fixture-editorial",
"slug": "fixture-editorial",
"displayName": "Fixture Editorial"
}
]
}

View File

@@ -0,0 +1,40 @@
/**
* Minimal Astro config for Playwright e2e tests.
*
* Uses env vars for the database path and optional marketplace URL
* so each test run gets an isolated database.
*/
import node from "@astrojs/node";
import react from "@astrojs/react";
import { colorPlugin } from "@emdashcms/plugin-color";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
const dbUrl = process.env.EMDASH_TEST_DB || "file:./test.db";
const marketplaceUrl = process.env.EMDASH_MARKETPLACE_URL || undefined;
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
react(),
emdash({
database: sqlite({ url: dbUrl }),
plugins: [colorPlugin()],
marketplace: marketplaceUrl,
sandboxRunner: marketplaceUrl ? "./noop-sandbox.mjs" : undefined,
}),
],
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: { fr: "en", es: "en" },
},
devToolbar: { enabled: false },
vite: {
server: {
fs: { strict: false },
},
},
});

38
e2e/fixture/emdash-env.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
// Generated by EmDash on dev server start
// Do not edit manually
/// <reference types="emdash/locals" />
import type { PortableTextBlock } from "emdash";
export interface Page {
id: string;
slug: string | null;
status: string;
title: string;
body?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
}
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
body?: PortableTextBlock[];
excerpt?: string;
theme_color?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
}
declare module "emdash" {
interface EmDashCollections {
pages: Page;
posts: Post;
}
}

View File

@@ -0,0 +1,10 @@
/**
* Noop sandbox runner for e2e tests.
*
* The marketplace admin pages only need `marketplace: true` in the manifest
* to render browse/detail UI. The sandbox runner is only used at install time.
* This stub satisfies the config validation without importing cloudflare:workers.
*/
import { createNoopSandboxRunner } from "emdash";
export { createNoopSandboxRunner as createSandboxRunner };

16
e2e/fixture/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "emdash-e2e-fixture",
"private": true,
"type": "module",
"dependencies": {
"@astrojs/node": "catalog:",
"@astrojs/react": "catalog:",
"@emdashcms/auth": "workspace:*",
"@emdashcms/plugin-color": "workspace:*",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
}
}

View File

@@ -0,0 +1,6 @@
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

@@ -0,0 +1,21 @@
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
<html>
<body>
<h1>Posts</h1>
<ul id="post-list">
{
posts.map((p) => (
<li>
<a href={`/posts/${p.id}`}>{p.data.title}</a>
{p.data.excerpt && <span class="excerpt">{p.data.excerpt}</span>}
</li>
))
}
</ul>
{posts.length === 0 && <p id="empty">No posts</p>}
</body>
</html>

View File

@@ -0,0 +1,21 @@
---
import { getEmDashEntry } from "emdash";
import { PortableText, Comments, CommentForm } from "emdash/ui";
const { slug } = Astro.params;
if (!slug) return Astro.redirect("/404");
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) return new Response("Not found", { status: 404 });
---
<html>
<body>
<article>
<h1 id="title">{post.data.title}</h1>
{post.data.excerpt && <p id="excerpt">{post.data.excerpt}</p>}
<div id="body"><PortableText value={post.data.body} /></div>
</article>
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
</body>
</html>

605
e2e/fixtures/admin.ts Normal file
View File

@@ -0,0 +1,605 @@
/**
* Admin Page Object for E2E tests
*
* Provides a clean API for interacting with the EmDash admin UI.
*/
import { type Page, expect } from "@playwright/test";
// Regex patterns
const ADMIN_URL_PATTERN = /\/_emdash\/admin/;
const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
const CONTENT_ID_EXTRACTION_PATTERN = /\/content\/[^/]+\/([^/]+)$/;
const MENU_URL_PATTERN = /\/_emdash\/admin\/menus\//;
const SETUP_PAGE_PATTERN = /\/_emdash\/admin\/setup/;
export class AdminPage {
readonly page: Page;
readonly baseUrl = "/_emdash/admin";
constructor(page: Page) {
this.page = page;
}
/**
* Authenticate using dev bypass (creates session)
* Call this before accessing protected pages.
*
* Navigates through the bypass URLs which sets cookies in the browser context.
*/
async devBypassAuth(): Promise<void> {
// Navigate to setup bypass - this sets up the site AND creates a session
// The redirect param sends us to auth bypass to ensure cookies are set
await this.page.goto("/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin/");
// Wait for the redirect to complete and admin shell to appear
await this.page.waitForURL(ADMIN_URL_PATTERN, { timeout: 30000 });
// Wait for page to be usable. Race networkidle (Vite dep re-optimization) against
// the hydration signal so HMR websocket can't stall us indefinitely.
await Promise.race([
this.page.waitForLoadState("networkidle").catch(() => {}),
this.waitForHydration().catch(() => {}),
]);
// Remove any vite error overlay that appeared during SSR
await this.dismissViteOverlay();
// If we got a server error, reload — the error is usually transient
const hasErrorOverlay = await this.page.locator("vite-error-overlay").count();
if (hasErrorOverlay > 0) {
await this.dismissViteOverlay();
await this.page.reload();
}
// Wait for the shell to fully hydrate
await this.waitForShell();
}
/**
* Navigate to an admin page
*/
async goto(path = "/"): Promise<void> {
const url = path === "/" ? this.baseUrl : `${this.baseUrl}${path}`;
await this.page.goto(url);
}
/**
* Wait for React hydration to complete.
* Astro removes the `ssr` attribute from `<astro-island>` after hydration.
*/
async waitForHydration(): Promise<void> {
await this.page.waitForSelector("astro-island:not([ssr])", { timeout: 15000 });
}
/**
* Wait for the admin shell to be ready (hydrated and interactive)
*/
async waitForShell(): Promise<void> {
// Dismiss vite error overlay if present (from previous request errors)
await this.dismissViteOverlay();
// Wait for sidebar to appear (indicates manifest loaded and React hydrated)
const maxRetries = 3;
let lastError: unknown;
for (let i = 0; i < maxRetries; i++) {
try {
// Wait for both sidebar and hydration signal
await this.page.waitForSelector('aside[aria-label="Admin navigation"]', {
timeout: 15000,
});
await this.waitForHydration();
lastError = undefined;
break;
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
// Server may be restarting (Vite re-optimization). Retry with reload.
// Wrap in try/catch since reload itself can fail if server is mid-restart.
try {
await this.dismissViteOverlay();
await this.page.reload({ waitUntil: "load" });
await this.dismissViteOverlay();
} catch {
// Server still down — wait for it to come back before next retry
await this.page.waitForLoadState("load").catch(() => {});
}
}
}
}
if (lastError) {
throw lastError;
}
// Dismiss the onboarding "Welcome" modal if it appears
await this.dismissOnboardingModal();
}
/**
* Dismiss the onboarding "Welcome" modal if it appears
*/
async dismissOnboardingModal(): Promise<void> {
const getStartedBtn = this.page.locator('button:has-text("Get Started")');
if (await getStartedBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await getStartedBtn.click();
await this.page
.locator("[data-base-ui-inert]")
.waitFor({ state: "hidden", timeout: 5000 })
.catch(() => {});
}
}
/**
* Dismiss vite-error-overlay if present
*/
async dismissViteOverlay(): Promise<void> {
// Remove vite-error-overlay from DOM if present — it has aria-hidden="true"
// so Playwright's isVisible() won't detect it, but it still blocks pointer events
await this.page
.evaluate(() => {
document.querySelectorAll("vite-error-overlay").forEach((el) => el.remove());
})
.catch(() => {});
}
/**
* Wait for loading states to complete
*/
async waitForLoading(): Promise<void> {
// Wait for loading text and spinners to disappear
await this.page
.locator("text=Loading")
.waitFor({ state: "hidden", timeout: 15000 })
.catch(() => {});
await this.page
.locator(".animate-spin")
.waitFor({ state: "hidden", timeout: 10000 })
.catch(() => {});
}
// ============================================
// Navigation
// ============================================
/**
* Navigate to dashboard
*/
async goToDashboard(): Promise<void> {
await this.goto("/");
await this.waitForShell();
}
/**
* Navigate to content list for a collection
*/
async goToContent(collection: string): Promise<void> {
await this.goto(`/content/${collection}`);
await this.waitForShell();
}
/**
* Navigate to create new content
*/
async goToNewContent(collection: string): Promise<void> {
await this.goto(`/content/${collection}/new`);
await this.waitForShell();
}
/**
* Navigate to edit content
*/
async goToEditContent(collection: string, id: string): Promise<void> {
await this.goto(`/content/${collection}/${id}`);
await this.waitForShell();
}
/**
* Navigate to media library
*/
async goToMedia(): Promise<void> {
await this.goto("/media");
await this.waitForShell();
}
/**
* Navigate to menus list
*/
async goToMenus(): Promise<void> {
await this.goto("/menus");
await this.waitForShell();
}
/**
* Navigate to edit a specific menu
*/
async goToMenuEditor(name: string): Promise<void> {
await this.goto(`/menus/${name}`);
await this.waitForShell();
}
/**
* Navigate to settings
*/
async goToSettings(): Promise<void> {
await this.goto("/settings");
await this.waitForShell();
}
/**
* Navigate to setup wizard
*/
async goToSetup(): Promise<void> {
await this.goto("/setup");
}
// ============================================
// Setup Wizard Actions
// ============================================
/**
* Complete the setup wizard
*/
async completeSetup(options: {
title: string;
tagline?: string;
includeContent?: boolean;
}): Promise<void> {
// Fill title
await this.page.fill("#title", options.title);
// Fill tagline if provided
if (options.tagline) {
await this.page.fill("#tagline", options.tagline);
}
// Handle content checkbox if it exists
if (options.includeContent !== undefined) {
const checkbox = this.page.locator("#includeContent");
if (await checkbox.isVisible()) {
const isChecked = await checkbox.isChecked();
if (options.includeContent && !isChecked) {
await checkbox.click();
} else if (!options.includeContent && isChecked) {
await checkbox.click();
}
}
}
// Submit
await this.page.click('button[type="submit"]');
}
// ============================================
// Content CRUD Actions
// ============================================
/**
* Create new content with field data
*/
async createContent(collection: string, data: Record<string, string>): Promise<string> {
await this.goToNewContent(collection);
// Fill in form fields
for (const [field, value] of Object.entries(data)) {
await this.fillField(field, value);
}
// Save
await this.clickSave();
await this.waitForSaveComplete();
// Return the new content ID from URL
const url = this.page.url();
const match = url.match(CONTENT_ID_EXTRACTION_PATTERN);
return match?.[1] || "";
}
/**
* Update content field
*/
async updateField(field: string, value: string): Promise<void> {
await this.fillField(field, value);
}
/**
* Fill a form field by slug (uses #field-{slug} convention)
*/
async fillField(slug: string, value: string): Promise<void> {
const input = this.page.locator(`#field-${slug}`);
await input.fill(value);
}
/**
* Click the save button
*/
async clickSave(): Promise<void> {
await this.page.locator('button:has-text("Save")').click();
}
/**
* Wait for save to complete
*/
async waitForSaveComplete(): Promise<void> {
// Wait for the save button to show "Saved" or stop showing "Saving..."
await this.page
.getByRole("button", { name: "Saved" })
.waitFor({ timeout: 10000 })
.catch(() => {});
await this.waitForLoading();
}
/**
* Delete content item by clicking delete button
*/
async deleteContentItem(title: string): Promise<void> {
// Find the row with this title and click delete
const row = this.page.locator("tr", { hasText: title });
await row.locator('button[aria-label*="Delete"]').click();
// Handle confirmation
this.page.once("dialog", (dialog) => dialog.accept());
}
// ============================================
// Media Library Actions
// ============================================
/**
* Upload a file to media library
*/
async uploadMedia(filePath: string): Promise<void> {
// Click upload button to trigger file input
const fileInput = this.page.locator('input[type="file"]');
await fileInput.setInputFiles(filePath);
// Wait for upload to complete
await this.page.waitForResponse(
(response) => response.url().includes("/api/media") && response.status() === 200,
);
await this.waitForLoading();
}
/**
* Get count of media items
*/
async getMediaCount(): Promise<number> {
const items = this.page.locator('[class*="grid"] > div');
return items.count();
}
/**
* Delete a media item by filename
*/
async deleteMedia(filename: string): Promise<void> {
// Hover over the item to show delete button
const item = this.page.locator(`[alt="${filename}"]`).first();
await item.hover();
// Click delete
const deleteBtn = this.page.locator('button:has-text("Delete")').first();
await deleteBtn.click();
// Handle confirmation
this.page.once("dialog", (dialog) => dialog.accept());
}
// ============================================
// Menu Actions
// ============================================
/**
* Create a new menu
*/
async createMenu(name: string, label: string): Promise<void> {
// Click create menu button
await this.page.getByRole("button", { name: "Create Menu" }).first().click();
// Fill form
await this.page.getByLabel("Name").fill(name);
await this.page.getByLabel("Label").fill(label);
// Submit and wait for navigation
await Promise.all([
this.page.waitForURL(MENU_URL_PATTERN, {
timeout: 15000,
}),
this.page.getByRole("button", { name: "Create" }).click(),
]);
}
/**
* Add a custom link to current menu
*/
async addMenuLink(label: string, url: string): Promise<void> {
// Click add link button
await this.page.getByRole("button", { name: "Add Custom Link" }).first().click();
// Wait for dialog to appear
await this.page.waitForSelector('[role="dialog"]', { state: "visible", timeout: 5000 });
// Fill form (scope to dialog to avoid ambiguity)
const dialog = this.page.locator('[role="dialog"]');
await dialog.getByLabel("Label").fill(label);
await dialog.getByLabel("URL").fill(url);
// Submit
await dialog.getByRole("button", { name: "Add" }).click();
// Wait for dialog to close
await this.page.waitForSelector('[role="dialog"]', { state: "hidden" });
}
/**
* Delete a menu
*/
async deleteMenu(name: string): Promise<void> {
// Find menu row and click delete
const menuRow = this.page.locator(`a[href*="/menus/${name}"]`).first();
const row = menuRow.locator("..");
await row.locator('button:has(svg[class*="Trash"])').click();
// Confirm deletion
await this.page.click('button:has-text("Delete"):not([disabled])');
}
/**
* Get list of menu items
*/
async getMenuItems(): Promise<string[]> {
const items = this.page.locator(".border.rounded-lg.p-4 .font-medium");
const texts = await items.allTextContents();
return texts;
}
// ============================================
// i18n / Translation Actions
// ============================================
/**
* Get the locale column values from the content list table.
* Returns empty array if locale column is not shown.
*/
async getLocaleColumnValues(): Promise<string[]> {
const cells = this.page.locator("table tbody tr td span.rounded.bg-kumo-tint");
return cells.allTextContents();
}
/**
* Get the locale badge shown in the content editor header.
* Returns null if no locale badge is visible.
*/
async getEditorLocaleBadge(): Promise<string | null> {
const badge = this.page
.locator("span.rounded.bg-kumo-tint.text-xs.font-semibold.uppercase")
.first();
if (await badge.isVisible({ timeout: 3000 }).catch(() => false)) {
return badge.textContent();
}
return null;
}
/**
* Get available translation locales from the translations sidebar.
* Returns an array of locale codes shown in the sidebar.
*/
async getTranslationSidebarLocales(): Promise<string[]> {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeCodes = sidebar.locator("span.text-xs.font-semibold.uppercase");
return localeCodes.allTextContents();
}
/**
* Click the "Translate" button for a specific locale in the translations sidebar.
*/
async clickTranslate(locale: string): Promise<void> {
// Find the translation row for this locale (the div containing the locale code)
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
await localeRow.getByRole("button", { name: "Translate" }).click();
}
/**
* Click "Edit" link for an existing translation in the sidebar.
*/
async clickEditTranslation(locale: string): Promise<void> {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
await localeRow.getByRole("link", { name: "Edit" }).click();
}
/**
* Check if a "Translate" button exists for a locale in the translations sidebar.
*/
async hasTranslateButton(locale: string): Promise<boolean> {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
return localeRow
.getByRole("button", { name: "Translate" })
.isVisible({ timeout: 3000 })
.catch(() => false);
}
/**
* Check if an "Edit" link exists for a locale in the translations sidebar.
*/
async hasEditTranslationLink(locale: string): Promise<boolean> {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
return localeRow
.getByRole("link", { name: "Edit" })
.isVisible({ timeout: 3000 })
.catch(() => false);
}
/**
* Get the locale switcher select value from the content list.
*/
async getLocaleFilterValue(): Promise<string | null> {
const select = this.page.locator("select").first();
if (await select.isVisible({ timeout: 3000 }).catch(() => false)) {
return select.inputValue();
}
return null;
}
/**
* Change the locale filter in the content list.
*/
async setLocaleFilter(locale: string): Promise<void> {
await this.page.locator("select").first().selectOption(locale);
await this.waitForLoading();
}
// ============================================
// Assertions
// ============================================
/**
* Assert we're on the dashboard
*/
async expectDashboard(): Promise<void> {
await expect(this.page).toHaveURL(ADMIN_DASHBOARD_PATTERN);
}
/**
* Assert we're on the setup page
*/
async expectSetupPage(): Promise<void> {
await expect(this.page).toHaveURL(SETUP_PAGE_PATTERN);
}
/**
* Assert a toast message appears
*/
async expectToast(text: string): Promise<void> {
await expect(this.page.locator('[role="status"]', { hasText: text })).toBeVisible();
}
/**
* Assert content exists in list
*/
async expectContentInList(title: string): Promise<void> {
await expect(this.page.locator("td", { hasText: title })).toBeVisible();
}
/**
* Assert content does not exist in list
*/
async expectContentNotInList(title: string): Promise<void> {
await expect(this.page.locator("td", { hasText: title })).not.toBeVisible();
}
/**
* Assert menu exists in list
*/
async expectMenuInList(label: string): Promise<void> {
await expect(this.page.locator("h3", { hasText: label })).toBeVisible();
}
/**
* Assert page title
*/
async expectPageTitle(title: string): Promise<void> {
await expect(this.page.locator("h1").first()).toContainText(title);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

54
e2e/fixtures/index.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* E2E Test Fixtures
*
* Extends Playwright's test with custom fixtures for EmDash admin testing.
* The server is started by global-setup.ts — these fixtures just provide
* the AdminPage helper and server context to each test.
*/
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test as base } from "@playwright/test";
import { AdminPage } from "./admin";
export { AdminPage } from "./admin";
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
interface ServerInfo {
pid: number;
workDir: string;
baseUrl: string;
marketplaceUrl: string;
token: string;
sessionCookie: string;
collections: string[];
contentIds: Record<string, string[]>;
mediaIds: Record<string, string>;
}
function getServerInfo(): ServerInfo {
return JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
}
/**
* Extended test with admin page fixture and server context
*/
export const test = base.extend<{
admin: AdminPage;
serverInfo: ServerInfo;
}>({
// eslint-disable-next-line no-empty-pattern
serverInfo: async ({}, use) => {
await use(getServerInfo());
},
admin: async ({ page }, use) => {
const admin = new AdminPage(page);
await use(admin);
},
});
export { expect } from "@playwright/test";

View File

@@ -0,0 +1,328 @@
/**
* Lightweight mock marketplace server for e2e tests.
*
* Serves canned JSON responses for the endpoints the admin UI hits:
* - GET /api/v1/plugins (search)
* - GET /api/v1/plugins/:id (detail)
* - GET /api/v1/themes (search)
* - GET /api/v1/themes/:id (detail)
* - GET /health (health check)
*
* Runs on a configurable port and returns deterministic fixture data.
*/
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
// ---------------------------------------------------------------------------
// Fixture data
// ---------------------------------------------------------------------------
// Matches MarketplacePluginSummary from plugins/marketplace.ts
const PLUGINS = [
{
id: "seo-toolkit",
name: "SEO Toolkit",
description: "Comprehensive SEO tools for your EmDash site.",
author: { name: "EmDash Labs", verified: true, avatarUrl: null },
capabilities: ["read:content", "write:content"],
keywords: ["seo", "meta", "sitemap"],
installCount: 12400,
hasIcon: false,
iconUrl: "",
createdAt: "2025-06-01T00:00:00Z",
updatedAt: "2026-02-15T00:00:00Z",
latestVersion: {
version: "2.1.0",
audit: { verdict: "pass", riskScore: 5 },
imageAudit: null,
},
},
{
id: "analytics-dashboard",
name: "Analytics Dashboard",
description: "Track page views and visitor metrics.",
author: { name: "DataCorp", verified: false, avatarUrl: null },
capabilities: ["network:fetch"],
keywords: ["analytics", "metrics"],
installCount: 3200,
hasIcon: false,
iconUrl: "",
createdAt: "2025-09-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
latestVersion: {
version: "1.5.0",
audit: { verdict: "warn", riskScore: 35 },
imageAudit: null,
},
},
{
id: "social-sharing",
name: "Social Sharing",
description: "Add social share buttons to your content.",
author: { name: "Community Plugins", verified: false, avatarUrl: null },
capabilities: ["read:content"],
keywords: ["social", "sharing"],
installCount: 890,
hasIcon: false,
iconUrl: "",
createdAt: "2026-01-10T00:00:00Z",
updatedAt: "2026-03-10T00:00:00Z",
latestVersion: {
version: "1.0.2",
audit: { verdict: "pass", riskScore: 8 },
imageAudit: null,
},
},
];
// Matches MarketplacePluginDetail from plugins/marketplace.ts
const PLUGIN_DETAILS: Record<string, object> = {
"seo-toolkit": {
...PLUGINS[0],
repositoryUrl: "https://github.com/emdash-labs/seo-toolkit",
homepageUrl: "https://emdash-labs.dev/seo-toolkit",
license: "MIT",
latestVersion: {
...PLUGINS[0]!.latestVersion,
minEmDashVersion: null,
bundleSize: 45000,
checksum: "abc123",
hasIcon: false,
screenshotCount: 0,
screenshotUrls: [],
capabilities: ["read:content", "write:content"],
status: "published",
readme:
"# SEO Toolkit\n\nA comprehensive SEO plugin for EmDash.\n\n## Features\n\n- Meta tag management\n- Open Graph support\n- Sitemap generation",
changelog: "## 2.1.0\n- Added sitemap generation\n- Fixed Open Graph preview",
publishedAt: "2026-02-15T00:00:00Z",
},
versions: [
{ version: "2.1.0", publishedAt: "2026-02-15T00:00:00Z" },
{ version: "2.0.0", publishedAt: "2025-12-01T00:00:00Z" },
{ version: "1.0.0", publishedAt: "2025-06-01T00:00:00Z" },
],
},
"analytics-dashboard": {
...PLUGINS[1],
repositoryUrl: "https://github.com/datacorp/analytics-dashboard",
homepageUrl: null,
license: "Apache-2.0",
latestVersion: {
...PLUGINS[1]!.latestVersion,
minEmDashVersion: null,
bundleSize: 32000,
checksum: "def456",
hasIcon: false,
screenshotCount: 0,
screenshotUrls: [],
capabilities: ["network:fetch"],
status: "published",
readme: "# Analytics Dashboard\n\nTrack visitors with a simple dashboard.",
changelog: "## 1.5.0\n- Improved chart rendering",
publishedAt: "2026-03-01T00:00:00Z",
},
versions: [
{ version: "1.5.0", publishedAt: "2026-03-01T00:00:00Z" },
{ version: "1.0.0", publishedAt: "2025-09-01T00:00:00Z" },
],
},
"social-sharing": {
...PLUGINS[2],
repositoryUrl: null,
homepageUrl: null,
license: "MIT",
latestVersion: {
...PLUGINS[2]!.latestVersion,
minEmDashVersion: null,
bundleSize: 12000,
checksum: "ghi789",
hasIcon: false,
screenshotCount: 0,
screenshotUrls: [],
capabilities: ["read:content"],
status: "published",
readme: "# Social Sharing\n\nAdd share buttons to your posts.",
changelog: "## 1.0.2\n- Bug fixes",
publishedAt: "2026-03-10T00:00:00Z",
},
versions: [{ version: "1.0.2", publishedAt: "2026-03-10T00:00:00Z" }],
},
};
// Matches MarketplaceThemeSummary from plugins/marketplace.ts
const THEMES = [
{
id: "minimal-blog",
name: "Minimal Blog",
description: "A clean, minimal blog theme.",
author: { name: "EmDash Labs", verified: true, avatarUrl: null },
keywords: ["blog", "minimal", "clean"],
previewUrl: "https://demo.emdashcms.com/themes/minimal-blog",
demoUrl: null,
hasThumbnail: false,
thumbnailUrl: null,
createdAt: "2025-08-01T00:00:00Z",
updatedAt: "2026-02-20T00:00:00Z",
},
{
id: "portfolio-pro",
name: "Portfolio Pro",
description: "Showcase your work with style.",
author: { name: "DesignStudio", verified: false, avatarUrl: null },
keywords: ["portfolio", "gallery", "creative"],
previewUrl: "https://demo.emdashcms.com/themes/portfolio-pro",
demoUrl: null,
hasThumbnail: false,
thumbnailUrl: null,
createdAt: "2025-11-15T00:00:00Z",
updatedAt: "2026-03-05T00:00:00Z",
},
];
// Matches MarketplaceThemeDetail from plugins/marketplace.ts
const THEME_DETAILS: Record<string, object> = {
"minimal-blog": {
...THEMES[0],
author: { id: "author-1", ...THEMES[0]!.author },
repositoryUrl: "https://github.com/emdash-labs/minimal-blog",
homepageUrl: "https://emdash-labs.dev/themes/minimal-blog",
license: "MIT",
screenshotCount: 0,
screenshotUrls: [],
},
"portfolio-pro": {
...THEMES[1],
author: { id: "author-2", ...THEMES[1]!.author },
repositoryUrl: null,
homepageUrl: null,
license: "MIT",
screenshotCount: 0,
screenshotUrls: [],
},
};
// ---------------------------------------------------------------------------
// URL patterns
// ---------------------------------------------------------------------------
const PLUGIN_DETAIL_PATTERN = /^\/api\/v1\/plugins\/([^/]+)$/;
const THEME_DETAIL_PATTERN = /^\/api\/v1\/themes\/([^/]+)$/;
// ---------------------------------------------------------------------------
// Request handler
// ---------------------------------------------------------------------------
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url || "/", `http://localhost`);
const path = url.pathname;
// CORS
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// Health
if (path === "/health") {
json(res, { status: "ok" });
return;
}
// Plugin search
if (path === "/api/v1/plugins" && req.method === "GET") {
const q = url.searchParams.get("q")?.toLowerCase() || "";
const capability = url.searchParams.get("capability") || "";
let items = [...PLUGINS];
if (q) {
items = items.filter(
(p) => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q),
);
}
if (capability) {
items = items.filter((p) => p.capabilities.includes(capability));
}
json(res, { items });
return;
}
// Plugin detail
const pluginMatch = path.match(PLUGIN_DETAIL_PATTERN);
if (pluginMatch && req.method === "GET") {
const id = pluginMatch[1]!;
const detail = PLUGIN_DETAILS[id];
if (detail) {
json(res, detail);
} else {
json(res, { error: { code: "NOT_FOUND", message: "Plugin not found" } }, 404);
}
return;
}
// Theme search
if (path === "/api/v1/themes" && req.method === "GET") {
const q = url.searchParams.get("q")?.toLowerCase() || "";
const keyword = url.searchParams.get("keyword") || "";
let items = [...THEMES];
if (q) {
items = items.filter(
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
);
}
if (keyword) {
items = items.filter((t) => t.keywords.includes(keyword));
}
json(res, { items });
return;
}
// Theme detail
const themeMatch = path.match(THEME_DETAIL_PATTERN);
if (themeMatch && req.method === "GET") {
const id = themeMatch[1]!;
const detail = THEME_DETAILS[id];
if (detail) {
json(res, detail);
} else {
json(res, { error: { code: "NOT_FOUND", message: "Theme not found" } }, 404);
}
return;
}
// 404
json(res, { error: { code: "NOT_FOUND", message: "Not found" } }, 404);
}
function json(res: ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
// ---------------------------------------------------------------------------
// Server lifecycle
// ---------------------------------------------------------------------------
export function startMockMarketplace(port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createServer(handleRequest);
server.on("error", reject);
server.listen(port, "127.0.0.1", () => {
resolve(server);
});
});
}
export function stopMockMarketplace(server: Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}

331
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,331 @@
/**
* Playwright global setup.
*
* Starts an isolated Astro dev server from the minimal e2e fixture,
* runs dev-bypass setup, and seeds test data. Writes server info
* to a temp file so tests and teardown can find it.
*/
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = resolve(__dirname, "..");
const FIXTURE_DIR = resolve(ROOT, "e2e/fixture");
const CLI_BINARY = resolve(ROOT, "packages/core/dist/cli/index.mjs");
const PORT = 4444;
const MARKETPLACE_PORT = 4445;
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
// Regex patterns
const COOKIE_VALUE_PATTERN = /^([^;]+)/;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function ensureBuilt(): Promise<void> {
if (existsSync(CLI_BINARY)) return;
console.log("[pw] Built artifacts missing — running pnpm build...");
await execAsync("pnpm", ["build"], { cwd: ROOT, timeout: 120_000 });
console.log("[pw] Build complete.");
}
/**
* Ensure all e2e fixture dependencies are built.
* The CI build filter (--filter emdash...) only builds emdash and its deps,
* not the fixture's plugin dependencies like @emdashcms/plugin-color.
*/
async function ensureFixtureDepsBuilt(): Promise<void> {
const colorDist = join(ROOT, "packages/plugins/color/dist/index.mjs");
if (existsSync(colorDist)) return;
console.log("[pw] Building e2e fixture dependencies...");
await execAsync("pnpm", ["run", "--filter", "emdash-e2e-fixture...", "build"], {
cwd: ROOT,
timeout: 120_000,
});
console.log("[pw] Fixture deps built.");
}
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (res.status > 0) return;
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
}
// ---------------------------------------------------------------------------
// Seed data
// ---------------------------------------------------------------------------
async function apiPost(baseUrl: string, token: string, path: string, body: unknown): Promise<any> {
const res = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`POST ${path} failed (${res.status}): ${text}`);
}
const json: any = await res.json();
return json.data ?? json;
}
async function apiUploadMedia(
baseUrl: string,
token: string,
filePath: string,
filename: string,
mimeType: string,
): Promise<{ id: string; storageKey: string; url: string }> {
const fileBuffer = readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
const formData = new FormData();
formData.append("file", blob, filename);
formData.append("width", "1");
formData.append("height", "1");
const res = await fetch(`${baseUrl}/_emdash/api/media`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
},
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Media upload failed (${res.status}): ${text}`);
}
const json: any = await res.json();
const item = (json.data ?? json).item;
return { id: item.id, storageKey: item.storageKey, url: item.url };
}
async function seedTestData(
baseUrl: string,
token: string,
): Promise<{
collections: string[];
contentIds: Record<string, string[]>;
mediaIds: Record<string, string>;
}> {
const collections: string[] = ["posts", "pages"];
const contentIds: Record<string, string[]> = {};
const mediaIds: Record<string, string> = {};
// Collections and fields are created by the fixture seed file
// (fixture/.emdash/seed.json) during dev-bypass setup.
const postIds: string[] = [];
let result: any;
result = await apiPost(baseUrl, token, "/_emdash/api/content/posts", {
data: { title: "First Post", excerpt: "The very first post" },
slug: "first-post",
});
postIds.push(result.item?.id ?? result.id);
await apiPost(baseUrl, token, `/_emdash/api/content/posts/${postIds[0]}/publish`, {});
result = await apiPost(baseUrl, token, "/_emdash/api/content/posts", {
data: { title: "Second Post", excerpt: "Another post" },
slug: "second-post",
});
postIds.push(result.item?.id ?? result.id);
await apiPost(baseUrl, token, `/_emdash/api/content/posts/${postIds[1]}/publish`, {});
result = await apiPost(baseUrl, token, "/_emdash/api/content/posts", {
data: { title: "Draft Post", excerpt: "Not published yet" },
slug: "draft-post",
});
postIds.push(result.item?.id ?? result.id);
// --- Upload test image and create post with image block ---
const testImagePath = join(ROOT, "e2e/fixtures/assets/test-image.png");
const media = await apiUploadMedia(baseUrl, token, testImagePath, "test-image.png", "image/png");
mediaIds["testImage"] = media.id;
result = await apiPost(baseUrl, token, "/_emdash/api/content/posts", {
data: {
title: "Post With Image",
excerpt: "A post containing an image block",
body: [
{
_type: "block",
_key: "b1",
style: "normal",
children: [{ _type: "span", _key: "s1", text: "Text before image." }],
markDefs: [],
},
{
_type: "image",
_key: "img1",
asset: { _ref: media.id, url: media.url },
alt: "Test image",
width: 1,
height: 1,
},
{
_type: "block",
_key: "b2",
style: "normal",
children: [{ _type: "span", _key: "s2", text: "Text after image." }],
markDefs: [],
},
],
},
slug: "post-with-image",
});
postIds.push(result.item?.id ?? result.id);
await apiPost(baseUrl, token, `/_emdash/api/content/posts/${postIds[3]}/publish`, {});
contentIds["posts"] = postIds;
const pageIds: string[] = [];
result = await apiPost(baseUrl, token, "/_emdash/api/content/pages", {
data: { title: "About" },
slug: "about",
});
pageIds.push(result.item?.id ?? result.id);
await apiPost(baseUrl, token, `/_emdash/api/content/pages/${pageIds[0]}/publish`, {});
result = await apiPost(baseUrl, token, "/_emdash/api/content/pages", {
data: { title: "Contact" },
slug: "contact",
});
pageIds.push(result.item?.id ?? result.id);
contentIds["pages"] = pageIds;
return { collections, contentIds, mediaIds };
}
// ---------------------------------------------------------------------------
// Global setup
// ---------------------------------------------------------------------------
export default async function globalSetup(): Promise<void> {
await ensureBuilt();
await ensureFixtureDepsBuilt();
// 0. Start mock marketplace server
const { startMockMarketplace } = await import("./fixtures/mock-marketplace.js");
const marketplaceServer = await startMockMarketplace(MARKETPLACE_PORT);
const marketplaceUrl = `http://127.0.0.1:${MARKETPLACE_PORT}`;
console.log(`[pw] Mock marketplace ready at ${marketplaceUrl}`);
// 1. Run the fixture in-place to avoid Astro beta CSS virtual module
// resolution bugs with symlinked temp dirs. Use a temp directory only for
// the database — source files stay at their real paths so Astro's virtual
// module resolver can find the compile metadata.
const workDir = FIXTURE_DIR;
const tempDataDir = mkdtempSync(join(tmpdir(), "emdash-pw-"));
const dbPath = join(tempDataDir, "test.db");
const fixtureNodeModules = join(FIXTURE_DIR, "node_modules");
const baseUrl = `http://localhost:${PORT}`;
// 2. Start dev server (with marketplace URL injected via env)
const astroBin = join(fixtureNodeModules, ".bin", "astro");
const server = spawn(astroBin, ["dev", "--port", String(PORT)], {
cwd: workDir,
env: {
...process.env,
EMDASH_TEST_DB: `file:${dbPath}`,
EMDASH_MARKETPLACE_URL: marketplaceUrl,
},
stdio: "pipe",
});
server.stdout?.on("data", (data: Buffer) => {
if (process.env.DEBUG) process.stderr.write(`[pw:${PORT}] ${data.toString()}`);
});
server.stderr?.on("data", (data: Buffer) => {
if (process.env.DEBUG) process.stderr.write(`[pw:${PORT}] ${data.toString()}`);
});
try {
// 3. Wait for server
console.log("[pw] Waiting for server...");
await waitForServer(`${baseUrl}/_emdash/api/setup/dev-bypass`, 60_000);
// 4. Run setup + create PAT
const setupRes = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`);
if (!setupRes.ok) {
const body = await setupRes.text().catch(() => "");
throw new Error(`Setup bypass failed (${setupRes.status}): ${body}`);
}
const setupJson: { data: { user: { id: string }; token?: string } } = await setupRes.json();
const setupData = setupJson.data;
const token = setupData.token;
if (!token) throw new Error("Setup bypass did not return a PAT token");
const setCookie = setupRes.headers.get("set-cookie");
let sessionCookie = "";
if (setCookie) {
const match = setCookie.match(COOKIE_VALUE_PATTERN);
if (match) sessionCookie = match[1]!;
}
// 5. Seed test data
console.log("[pw] Seeding test data...");
const seed = await seedTestData(baseUrl, token);
// 5b. Warm up pages that use emdash/ui (triggers Astro compilation of all
// component virtual modules, avoiding race conditions in tests)
console.log("[pw] Warming up pages...");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const resp = await fetch(`${baseUrl}/posts/post-with-image`);
if (resp.ok) break;
// Retry on compilation errors — Astro may need multiple passes
await new Promise((r) => setTimeout(r, 1000));
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
// 6. Write server info
const info = {
pid: server.pid!,
workDir,
tempDataDir,
baseUrl,
marketplaceUrl,
token,
sessionCookie,
collections: seed.collections,
contentIds: seed.contentIds,
mediaIds: seed.mediaIds,
};
writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2));
console.log(`[pw] Server ready at ${baseUrl} (pid ${server.pid})`);
} catch (error) {
server.kill("SIGTERM");
marketplaceServer.close();
throw error;
}
}

63
e2e/global-teardown.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Playwright global teardown.
*
* Kills the dev server, removes the temp data directory,
* and cleans up the node_modules symlink if we created it.
*/
import { existsSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const FIXTURE_DIR = resolve(ROOT, "e2e/fixture");
export default async function globalTeardown(): Promise<void> {
if (!existsSync(SERVER_INFO_PATH)) return;
try {
const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
// Kill the server process
try {
process.kill(info.pid, "SIGTERM");
await new Promise((r) => setTimeout(r, 1000));
try {
process.kill(info.pid, 0); // Check if alive
process.kill(info.pid, "SIGKILL");
} catch {
// Already dead
}
} catch {
// Process may already be dead
}
// Remove temp data directory (database + media files)
if (info.tempDataDir && existsSync(info.tempDataDir)) {
rmSync(info.tempDataDir, { recursive: true, force: true });
}
// Clean up build artifacts from the fixture dir
const astroDir = join(FIXTURE_DIR, ".astro");
if (existsSync(astroDir)) {
rmSync(astroDir, { recursive: true, force: true });
}
// Clean up generated .emdash subdirs (uploads, etc.) but preserve seed.json
const emdashDir = join(FIXTURE_DIR, ".emdash");
if (existsSync(emdashDir)) {
for (const entry of readdirSync(emdashDir)) {
if (entry === "seed.json") continue;
const entryPath = join(emdashDir, entry);
rmSync(entryPath, { recursive: true, force: true });
}
}
} finally {
unlinkSync(SERVER_INFO_PATH);
}
console.log("[pw] Server stopped and temp directory cleaned up.");
}

View File

@@ -0,0 +1,146 @@
/**
* Accessibility E2E Tests
*
* Automated WCAG 2.1 AA audit using axe-core.
* Tests for critical and high-priority accessibility issues across admin pages.
*/
import AxeBuilder from "@axe-core/playwright";
import { test, expect } from "../fixtures";
// Regex patterns for URL assertions (anchored to prevent false matches)
const ADMIN_ROOT_URL = /\/_emdash\/admin\/?(?:[?#].*)?$/;
const CONTENT_POSTS_URL = /\/content\/posts\/?(?:[?#].*)?$/;
const CONTENT_POSTS_NEW_URL = /\/content\/posts\/new\/?(?:[?#].*)?$/;
const MEDIA_URL = /\/media\/?(?:[?#].*)?$/;
const USERS_URL = /\/users\/?(?:[?#].*)?$/;
const SETTINGS_URL = /\/settings\/?(?:[?#].*)?$/;
// Known a11y violations from upstream dependencies:
// - color-contrast: kumo design system colors on white backgrounds (needs upstream fix)
// - aria-valid-attr-value: Base UI's Collapsible sets aria-controls on triggers pointing
// to panel IDs that may not be in the DOM when collapsed (kumo Sidebar collapsible groups)
const KNOWN_A11Y_EXCLUSIONS = ["color-contrast", "aria-valid-attr-value"];
test.describe("Accessibility Audit", () => {
test.describe("Login Page", () => {
test("should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goto("/login");
// Wait for stable content — admin pages need Astro compilation on first hit
await expect(admin.page.locator("h1")).toContainText("Sign in", { timeout: 15000 });
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
});
test.describe("Authenticated Pages", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("dashboard should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goToDashboard();
await admin.waitForLoading();
await expect(admin.page).toHaveURL(ADMIN_ROOT_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("content list should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
await expect(admin.page).toHaveURL(CONTENT_POSTS_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("content editor should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
await expect(admin.page).toHaveURL(CONTENT_POSTS_NEW_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.exclude(".ProseMirror") // Rich text editor has complex a11y needs
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("media library should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goToMedia();
await admin.waitForLoading();
await expect(admin.page).toHaveURL(MEDIA_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("users page should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goto("/users");
await admin.waitForShell();
await admin.waitForLoading();
await expect(admin.page).toHaveURL(USERS_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("settings page should have no WCAG 2.x AA violations", async ({ admin }) => {
await admin.goToSettings();
await admin.waitForLoading();
await expect(admin.page).toHaveURL(SETTINGS_URL);
const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect(results.violations).toEqual([]);
});
test("content list should be keyboard navigable", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Tab through key interactive elements
await admin.page.keyboard.press("Tab");
const focusedElements: string[] = [];
for (let i = 0; i < 10; i++) {
const focused = await admin.page.evaluate(() => document.activeElement?.tagName || "");
focusedElements.push(focused);
await admin.page.keyboard.press("Tab");
}
// Should have found interactive elements (buttons, links)
expect(focusedElements.some((el) => el === "BUTTON" || el === "A")).toBe(true);
});
});
});

View File

@@ -0,0 +1,378 @@
/**
* Admin UI fix verification tests
*
* API correctness:
* 1. Media metadata updates (alt text, dimensions) save and persist
* 2. Upload success toast only appears after upload completes
* 3. Taxonomy term deletion shows ConfirmDialog (not browser confirm)
*
* Content editor performance:
* 4. Autosave still triggers correctly after useMemo/useRef optimizations
*/
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { test, expect } from "../fixtures";
// ---------- regex patterns (module scope for linter) ----------
const MEDIA_API_PATTERN = /\/api\/media/;
const MEDIA_UPLOAD_PATTERN = /\/api\/media(?:\/upload-url)?$/;
const MEDIA_PUT_PATTERN = /\/api\/media\//;
const TAXONOMY_DELETE_PATTERN = /\/api\/taxonomies\//;
const ALT_LABEL_PATTERN = /alt/i;
const SAVE_BUTTON_PATTERN = /save/i;
// ---------- helpers ----------
const TEST_ASSETS_DIR = join(process.cwd(), "e2e/fixtures/assets");
function ensureTestImage(): string {
if (!existsSync(TEST_ASSETS_DIR)) mkdirSync(TEST_ASSETS_DIR, { recursive: true });
const testImagePath = join(TEST_ASSETS_DIR, "test-image.png");
if (!existsSync(testImagePath)) {
// Minimal valid PNG (1x1 red pixel)
const pngData = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe, 0xd4, 0xef, 0x00, 0x00,
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
writeFileSync(testImagePath, pngData);
}
return testImagePath;
}
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
// ==========================================================================
// Media metadata updates save and persist (updateMedia return path fix)
// ==========================================================================
test.describe("Media metadata updates", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("updating alt text on a media item persists after reload", async ({ admin, page }) => {
const testImagePath = ensureTestImage();
// Upload an image via the UI
await admin.goToMedia();
await admin.waitForLoading();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testImagePath);
await page.waitForResponse((res) => MEDIA_API_PATTERN.test(res.url()) && res.status() === 200, {
timeout: 10000,
});
await admin.waitForLoading();
// Wait for the image to appear in the grid
const mediaGrid = page.locator(".grid.gap-4");
await expect(mediaGrid.locator("img").first()).toBeVisible({ timeout: 5000 });
// Click the image to open the detail panel
await mediaGrid.locator("button").first().click();
// Wait for the detail panel — it's a slide-out div, not a dialog
await expect(page.locator("text=Media Details")).toBeVisible({ timeout: 5000 });
// Find the alt text input and fill it
const altInput = page.getByLabel(ALT_LABEL_PATTERN);
await expect(altInput).toBeVisible({ timeout: 3000 });
const altText = `Test alt ${Date.now()}`;
await altInput.fill(altText);
// The Save button should become enabled after editing
const saveButton = page.getByRole("button", { name: SAVE_BUTTON_PATTERN });
await expect(saveButton).toBeEnabled({ timeout: 3000 });
// Intercept the PUT response to verify the server returns the item
const putResponse = page.waitForResponse(
(res) =>
MEDIA_PUT_PATTERN.test(res.url()) &&
res.request().method() === "PUT" &&
res.status() === 200,
{ timeout: 10000 },
);
await saveButton.click();
const response = await putResponse;
// Verify the response contains the item (the fix: result.item, not result.data?.item)
const body = await response.json();
expect(body.data.item).toBeDefined();
expect(body.data.item.alt).toBe(altText);
});
});
// ==========================================================================
// Upload success only after completion (premature success fix)
// ==========================================================================
test.describe("Upload completion timing", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("upload success feedback appears only after server responds", async ({ admin, page }) => {
const testImagePath = ensureTestImage();
await admin.goToMedia();
await admin.waitForLoading();
// Set up response listener BEFORE triggering upload
const uploadResponse = page.waitForResponse(
(res) =>
MEDIA_UPLOAD_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 15000 },
);
// Trigger upload
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testImagePath);
// Should see uploading state (or at least no success yet while request is pending)
// Wait for the response to come back
await uploadResponse;
// Now success feedback should appear
const successIndicator = page.locator('text="File uploaded"');
await expect(successIndicator).toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// Taxonomy term deletion uses ConfirmDialog instead of browser confirm()
// ==========================================================================
test.describe("Taxonomy ConfirmDialog", () => {
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
});
test("deleting a taxonomy term shows a dialog instead of browser confirm", async ({
admin,
page,
serverInfo,
}) => {
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Check if any taxonomies exist
const taxRes = await fetch(`${baseUrl}/_emdash/api/taxonomies`, { headers });
const taxData: any = await taxRes.json();
if (!taxData.data?.taxonomies || taxData.data.taxonomies.length === 0) {
test.skip();
return;
}
const taxonomy = taxData.data.taxonomies[0];
// Create a term to delete
const termRes = await fetch(`${baseUrl}/_emdash/api/taxonomies/${taxonomy.name}/terms`, {
method: "POST",
headers,
body: JSON.stringify({
slug: `e2e-delete-test-${Date.now()}`,
label: "E2E Delete Test Term",
}),
});
if (!termRes.ok) {
test.skip();
return;
}
// Navigate to the taxonomy page
await admin.goto(`/taxonomies/${taxonomy.name}`);
await admin.waitForShell();
await admin.waitForLoading();
// Ensure the term we created is visible
await expect(page.locator("text=E2E Delete Test Term")).toBeVisible({ timeout: 5000 });
// Set up a listener to ensure NO browser dialog appears
let browserDialogAppeared = false;
page.on("dialog", async (dialog) => {
browserDialogAppeared = true;
await dialog.dismiss();
});
// Click the delete button for the term
await page.getByRole("button", { name: "Delete E2E Delete Test Term" }).click();
// A ConfirmDialog (React dialog) should appear — NOT a browser confirm()
// The dialog title is "Delete {labelSingular}?" e.g. "Delete Category?"
const confirmDialog = page.locator('[role="dialog"]').filter({ hasText: "permanently" });
await expect(confirmDialog).toBeVisible({ timeout: 3000 });
// The dialog should have a "Delete" button and "Cancel" button
await expect(confirmDialog.getByRole("button", { name: "Delete" })).toBeVisible();
await expect(confirmDialog.getByRole("button", { name: "Cancel" })).toBeVisible();
// The dialog should mention the term name (rendered in curly quotes)
await expect(confirmDialog.getByText("E2E Delete Test Term")).toBeVisible();
// No browser dialog should have appeared
expect(browserDialogAppeared).toBe(false);
// Actually delete it (confirm)
const deleteResponse = page.waitForResponse(
(res) => TAXONOMY_DELETE_PATTERN.test(res.url()) && res.request().method() === "DELETE",
{ timeout: 10000 },
);
await confirmDialog.getByRole("button", { name: "Delete" }).click();
await deleteResponse;
// Dialog should close
await expect(confirmDialog).not.toBeVisible({ timeout: 5000 });
// Term should be gone
await expect(page.locator("text=E2E Delete Test Term")).not.toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// Autosave still triggers after useMemo/useRef perf optimizations
// ==========================================================================
test.describe("Autosave after perf optimizations", () => {
let collectionSlug: string;
let postId: string;
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Create a collection with revision + draft support
collectionSlug = `autosave_test_${Date.now()}`;
await fetch(`${baseUrl}/_emdash/api/schema/collections`, {
method: "POST",
headers,
body: JSON.stringify({
slug: collectionSlug,
label: "Autosave Test Collection",
labelSingular: "Autosave Test Collection",
supports: ["revisions", "drafts"],
}),
});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers,
body: JSON.stringify({ slug: "title", type: "string", label: "Title", required: true }),
});
// Create a draft post (autosave works on existing items, no need to publish)
const createRes = await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}`, {
method: "POST",
headers,
body: JSON.stringify({ data: { title: "Autosave Test" }, slug: "autosave-perf-test" }),
});
const createData: any = await createRes.json();
postId = createData.data?.item?.id ?? createData.data?.id;
});
test.afterEach(async () => {
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "DELETE",
headers,
}).catch(() => {});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
test("autosave triggers after editing content (useMemo/useRef optimizations)", async ({
admin,
page,
}) => {
const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`;
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
const titleInput = page.locator("#field-title");
await expect(titleInput).toHaveValue("Autosave Test");
// Wait for a PUT whose request body contains the updated title
// (an initial autosave with old data may fire first — skip it)
const autosavePut = page.waitForResponse(
(res) => {
if (!res.url().includes(contentUrl) || res.request().method() !== "PUT") return false;
const postData = res.request().postData() ?? "";
return postData.includes("Autosave Perf Test Edit");
},
{ timeout: 15000 },
);
await titleInput.fill("Autosave Perf Test Edit");
const response = await autosavePut;
// Autosave should succeed (200)
expect(response.status()).toBe(200);
// The autosave indicator should show "Saved"
await expect(page.locator("text=Saved")).toBeVisible({ timeout: 5000 });
});
test("multiple rapid edits result in single autosave (debounce still works)", async ({
admin,
page,
}) => {
const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`;
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
const titleInput = page.locator("#field-title");
await expect(titleInput).toHaveValue("Autosave Test");
// Wait for any initial autosave to settle before tracking
await page.waitForTimeout(3000);
// Track PUT requests only from this point forward
const putRequests: any[] = [];
page.on("response", (res) => {
if (res.url().includes(contentUrl) && res.request().method() === "PUT") {
putRequests.push(res);
}
});
// Type multiple characters rapidly (within the 2s debounce window)
await titleInput.fill("");
await titleInput.pressSequentially("ABCDEF", { delay: 50 });
// Wait for autosave to trigger (debounce is 2s + some margin)
await page.waitForTimeout(4000);
// Should have exactly 1 PUT request (debounced)
expect(putRequests.length).toBe(1);
// Verify the PUT sent the correct final value
const postData = putRequests[0].request().postData() ?? "";
expect(postData).toContain("ABCDEF");
});
});

View File

@@ -0,0 +1,236 @@
/**
* Allowed Domains E2E Tests
*
* Tests self-signup domain management in admin settings.
* Available at /settings/allowed-domains.
*
* Uses API to add/remove domains as needed, verifies UI reflects changes.
*/
import { test, expect } from "../fixtures";
// API helper
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
test.describe("Allowed Domains Settings", () => {
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Clean up any leftover test domains from previous runs
const res = await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, { headers });
if (res.ok) {
const data: any = await res.json();
const domains = data.data?.domains ?? [];
for (const d of domains) {
if (d.domain.includes("e2e-test")) {
await fetch(
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(d.domain)}`,
{ method: "DELETE", headers },
).catch(() => {});
}
}
}
});
test("renders the allowed domains settings page", async ({ admin, page }) => {
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
// Should show the page title
await expect(page.locator("h1").first()).toContainText("Self-Signup Domains");
// Should show the "Allowed Domains" section heading
await expect(page.locator("h2", { hasText: "Allowed Domains" })).toBeVisible({
timeout: 10000,
});
// Should have an "Add Domain" button
await expect(page.getByRole("button", { name: "Add Domain" })).toBeVisible();
});
test("shows empty state when no domains configured", async ({ admin, page }) => {
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
// Should show empty state message (unless domains were pre-configured)
const emptyState = page.locator("text=No domains configured");
// Check if there's already domain data or empty state
const hasEmptyState = await emptyState.isVisible({ timeout: 5000 }).catch(() => false);
if (hasEmptyState) {
await expect(emptyState).toBeVisible();
}
// Either way, the Add Domain button should be there
await expect(page.getByRole("button", { name: "Add Domain" })).toBeVisible();
});
test("adds a new domain via the UI", async ({ admin, page }) => {
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
const testDomain = `e2e-test-${Date.now()}.example.com`;
// Click "Add Domain" to open the inline form
await page.getByRole("button", { name: "Add Domain" }).click();
// The add form should appear with a domain input
const domainInput = page.getByLabel("Domain");
await expect(domainInput).toBeVisible({ timeout: 5000 });
// Fill in the domain
await domainInput.fill(testDomain);
// Click "Add Domain" submit button (different from the trigger button)
const submitButton = page.getByRole("button", { name: "Add Domain" });
await submitButton.click();
// Wait for the domain to appear in the list
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible({
timeout: 10000,
});
// Should show a success status message
const successMsg = page.locator("text=Domain added successfully");
await expect(successMsg).toBeVisible({ timeout: 5000 });
// Clean up via API
await fetch(
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
{ method: "DELETE", headers },
).catch(() => {});
});
test("removes a domain via the UI", async ({ admin, page }) => {
const testDomain = `e2e-test-delete-${Date.now()}.example.com`;
// Create domain via API first
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, {
method: "POST",
headers,
body: JSON.stringify({ domain: testDomain, defaultRole: 30 }),
});
// Navigate to the page
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
// Verify the domain is visible
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible({
timeout: 10000,
});
// Click the delete button for this domain
const deleteButton = page.getByRole("button", { name: `Delete ${testDomain}` });
await deleteButton.click();
// The confirmation dialog should appear
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByRole("heading", { name: "Remove Domain" })).toBeVisible();
// Confirm deletion
await dialog.getByRole("button", { name: "Remove Domain" }).click();
// Domain should disappear from the list
await expect(page.locator(`.font-medium`, { hasText: testDomain })).not.toBeVisible({
timeout: 10000,
});
// Should show a success status message
const successMsg = page.locator("text=Domain removed");
await expect(successMsg).toBeVisible({ timeout: 5000 });
});
test("cancel delete keeps the domain", async ({ admin, page }) => {
const testDomain = `e2e-test-keep-${Date.now()}.example.com`;
// Create domain via API
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, {
method: "POST",
headers,
body: JSON.stringify({ domain: testDomain, defaultRole: 30 }),
});
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
// Verify domain is visible
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible({
timeout: 10000,
});
// Click delete
await page.getByRole("button", { name: `Delete ${testDomain}` }).click();
// Dialog appears
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Cancel
await dialog.getByRole("button", { name: "Cancel" }).click();
// Dialog should close
await expect(dialog).not.toBeVisible({ timeout: 5000 });
// Domain should still be there
await expect(page.locator(`.font-medium`, { hasText: testDomain })).toBeVisible();
// Clean up
await fetch(
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
{ method: "DELETE", headers },
).catch(() => {});
});
test("toggling enabled/disabled updates the domain", async ({ admin, page }) => {
const testDomain = `e2e-test-toggle-${Date.now()}.example.com`;
// Create domain via API
await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, {
method: "POST",
headers,
body: JSON.stringify({ domain: testDomain, defaultRole: 30 }),
});
await admin.goto("/settings/allowed-domains");
await admin.waitForShell();
await admin.waitForLoading();
// Find the domain row
const domainRow = page.locator("div.flex.items-center.justify-between").filter({
hasText: testDomain,
});
await expect(domainRow).toBeVisible({ timeout: 10000 });
// Find the switch toggle in the row and click it
const toggle = domainRow.locator("button[role='switch']");
await toggle.click();
// Wait for the update to complete -- success message should appear
const statusMsg = page.locator("text=Domain updated");
await expect(statusMsg).toBeVisible({ timeout: 5000 });
// Clean up
await fetch(
`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(testDomain)}`,
{ method: "DELETE", headers },
).catch(() => {});
});
});

View File

@@ -0,0 +1,226 @@
/**
* API Tokens E2E Tests
*
* Tests for the API Tokens settings page:
* - Page renders with existing tokens
* - Creating a new token (name, scopes, display)
* - Token value is only shown once
* - Revoking a token with inline confirmation
*/
import { test, expect } from "../fixtures";
// Regex patterns
const MASKED_TOKEN_PATTERN = /^[•]+$/;
const TOKEN_PREFIX_PATTERN = /^ec_/;
test.describe("API Tokens", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("tokens page renders with existing tokens", async ({ admin, page }) => {
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
// Should show the page title
await admin.expectPageTitle("API Tokens");
// The dev-bypass setup creates a token ("dev-bypass-token") so the list
// should not be empty. Look for the token list container with at least one
// entry showing a token name and prefix.
const tokenList = page.locator(".divide-y");
await expect(tokenList).toBeVisible({ timeout: 10000 });
// At least one token row should be present
const tokenRows = tokenList.locator("> div");
await expect(tokenRows.first()).toBeVisible({ timeout: 5000 });
// The setup token should show its name
await expect(page.locator("text=dev-bypass-token")).toBeVisible();
});
test("create a new token with scopes", async ({ admin, page }) => {
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
const tokenName = `e2e-test-token-${Date.now()}`;
// Click the "Create Token" button to show the form
await page.getByRole("button", { name: "Create Token" }).click();
// The create form should appear with the heading
await expect(page.locator("text=Create New Token")).toBeVisible({ timeout: 5000 });
// Fill in the token name
const nameInput = page.getByLabel("Token Name");
await nameInput.fill(tokenName);
// Select at least one scope -- click the label to toggle the checkbox
await page.locator("label", { hasText: "Content Read" }).click();
// Select "Media Read" too
await page.locator("label", { hasText: "Media Read" }).click();
// Submit the form -- use last() to get the submit button, not the header button
await page.getByRole("button", { name: "Create Token" }).last().click();
// Wait for the token created confirmation
await page.waitForTimeout(2000);
// The new token banner should appear with the token value
await expect(page.locator("text=Token created")).toBeVisible({ timeout: 5000 });
await expect(page.locator(`text=${tokenName}`).first()).toBeVisible();
// The "won't be shown again" warning should be visible
await expect(page.locator("text=won't be shown again")).toBeVisible();
// The token value should be masked by default (dots)
const tokenDisplay = page.locator("code").filter({ hasText: MASKED_TOKEN_PATTERN });
await expect(tokenDisplay).toBeVisible();
// Click the eye icon to reveal the token
await page.getByLabel("Show token").click();
// After revealing, the code block should show a real token (starts with "ec_")
const revealedToken = page.locator("code").filter({ hasText: TOKEN_PREFIX_PATTERN }).first();
await expect(revealedToken).toBeVisible({ timeout: 3000 });
// The token should also appear in the list below
const tokenList = page.locator(".divide-y");
await expect(tokenList.locator("text=" + tokenName)).toBeVisible({ timeout: 5000 });
});
test("token value is not visible after navigating away and back", async ({ admin, page }) => {
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
const tokenName = `ephemeral-token-${Date.now()}`;
// Create a token
await page.getByRole("button", { name: "Create Token" }).click();
await expect(page.locator("text=Create New Token")).toBeVisible({ timeout: 5000 });
await page.getByLabel("Token Name").fill(tokenName);
// Select "Content Read" scope
await page.locator("label", { hasText: "Content Read" }).click();
// Submit -- use last() to get the form submit button, not the header button
await page.getByRole("button", { name: "Create Token" }).last().click();
await page.waitForTimeout(2000);
// Verify the banner is showing
await expect(page.locator("text=Token created")).toBeVisible({ timeout: 5000 });
// Navigate away to settings
await admin.goto("/settings");
await admin.waitForShell();
// Navigate back to API tokens
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
// The "Token created" banner should NOT be visible
await expect(page.locator("text=Token created")).not.toBeVisible({ timeout: 3000 });
// But the token should still appear in the list (by name)
await expect(page.locator(".divide-y").locator(`text=${tokenName}`)).toBeVisible({
timeout: 5000,
});
});
test("revoke a token with confirmation", async ({ admin, page }) => {
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
const tokenName = `revoke-me-${Date.now()}`;
// Create a token to revoke
await page.getByRole("button", { name: "Create Token" }).click();
await expect(page.locator("text=Create New Token")).toBeVisible({ timeout: 5000 });
await page.getByLabel("Token Name").fill(tokenName);
await page.locator("label", { hasText: "Content Read" }).click();
await page.getByRole("button", { name: "Create Token" }).last().click();
await page.waitForTimeout(2000);
// Dismiss the new token banner
await page.getByLabel("Dismiss").click();
await expect(page.locator("text=Token created")).not.toBeVisible({ timeout: 3000 });
// Find the token row for our new token (NOT the dev-bypass-token)
const tokenRow = page.locator(".divide-y > div").filter({ hasText: tokenName });
await expect(tokenRow).toBeVisible({ timeout: 5000 });
// Click the revoke (trash) button on our token's row
await tokenRow.getByLabel("Revoke token").click();
// An inline confirmation should appear with "Revoke?" text
await expect(tokenRow.locator("text=Revoke?")).toBeVisible({ timeout: 3000 });
// Should have Confirm and Cancel buttons
await expect(tokenRow.getByRole("button", { name: "Confirm" })).toBeVisible();
await expect(tokenRow.getByRole("button", { name: "Cancel" })).toBeVisible();
// Click Confirm to revoke
await tokenRow.getByRole("button", { name: "Confirm" }).click();
await page.waitForTimeout(2000);
// The token should disappear from the list
await expect(page.locator(".divide-y").locator(`text=${tokenName}`)).not.toBeVisible({
timeout: 5000,
});
// The dev-bypass-token should still be present (we didn't revoke it)
await expect(page.locator("text=dev-bypass-token")).toBeVisible();
});
test("cancel revoke keeps token in list", async ({ admin, page }) => {
await admin.goto("/settings/api-tokens");
await admin.waitForShell();
await admin.waitForLoading();
const tokenName = `keep-me-${Date.now()}`;
// Create a token
await page.getByRole("button", { name: "Create Token" }).click();
await expect(page.locator("text=Create New Token")).toBeVisible({ timeout: 5000 });
await page.getByLabel("Token Name").fill(tokenName);
await page.locator("label", { hasText: "Content Read" }).click();
await page.getByRole("button", { name: "Create Token" }).last().click();
await page.waitForTimeout(2000);
await page.getByLabel("Dismiss").click();
// Find the token row
const tokenRow = page.locator(".divide-y > div").filter({ hasText: tokenName });
await expect(tokenRow).toBeVisible({ timeout: 5000 });
// Click revoke
await tokenRow.getByLabel("Revoke token").click();
await expect(tokenRow.locator("text=Revoke?")).toBeVisible({ timeout: 3000 });
// Click Cancel instead
await tokenRow.getByRole("button", { name: "Cancel" }).click();
// Confirmation UI should disappear
await expect(tokenRow.locator("text=Revoke?")).not.toBeVisible({ timeout: 3000 });
// Token should still be in the list
await expect(page.locator(".divide-y").locator(`text=${tokenName}`)).toBeVisible();
// Trash icon should be back
await expect(tokenRow.getByLabel("Revoke token")).toBeVisible();
});
});

239
e2e/tests/auth.spec.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* Authentication E2E Tests
*
* Tests for authentication features:
* - Login page UI
* - Dev bypass authentication
* - Session persistence
* - Logout
* - Protected routes redirect to login
* - User management (admin only)
* - Security settings (passkey management)
*
* Runs against an isolated fixture with seeded data.
*/
import { test, expect } from "../fixtures";
// Regex patterns
const LOGIN_URL_PATTERN = /\/login/;
const ADMIN_URL_PATTERN = /\/_emdash\/admin\/?$/;
const USERS_URL_PATTERN = /\/users/;
const SECURITY_SETTINGS_URL_PATTERN = /\/settings\/security/;
const LOGIN_OR_ADMIN_URL_PATTERN = /\/(login|admin)/;
const SECURITY_MENUITEM_REGEX = /Security/i;
const ADD_PASSKEY_REGEX = /Add Passkey/i;
const SIGN_HEADING_REGEX = /sign/i;
test.describe("Authentication", () => {
test.describe("Login Page", () => {
test("displays login page with passkey button", async ({ admin }) => {
await admin.goto("/login");
// Should show login page
await expect(admin.page.locator("h1")).toContainText("Sign in");
// Should have passkey login button
await expect(admin.page.locator('button:has-text("Sign in with Passkey")')).toBeVisible();
});
test("signup link is hidden when no allowed domains", async ({ admin }) => {
await admin.goto("/login");
// No allowed domains are seeded, so signup should not be visible
await expect(admin.page.locator('a:has-text("Sign up")')).not.toBeVisible();
});
});
test.describe("Protected Routes", () => {
test("unauthenticated access redirects to login", async ({ admin }) => {
// Clear cookies to ensure no session
await admin.page.context().clearCookies();
// Try to access dashboard without auth
await admin.goto("/");
// Should redirect to login (setup is already done via global-setup)
await expect(admin.page).toHaveURL(LOGIN_URL_PATTERN);
});
});
test.describe("Dev Bypass Authentication", () => {
test("dev bypass creates session and allows access", async ({ admin }) => {
// Use dev bypass to authenticate
await admin.devBypassAuth();
// Now navigate to admin
await admin.goto("/");
// Should see dashboard shell (sidebar with navigation)
await admin.waitForShell();
// Should be on admin URL (not redirected to login)
await expect(admin.page).toHaveURL(ADMIN_URL_PATTERN);
});
test("session persists across page loads", async ({ admin }) => {
await admin.devBypassAuth();
await admin.goto("/");
await admin.waitForShell();
// Navigate to another page via sidebar link
await admin.page.click('a:has-text("Users")');
await admin.waitForShell();
// Should still be authenticated and see users page
await expect(admin.page).toHaveURL(USERS_URL_PATTERN);
await admin.expectPageTitle("Users");
});
});
test.describe("Logout", () => {
test("logout clears session and redirects to login", async ({ admin }) => {
// Authenticate first
await admin.devBypassAuth();
await admin.goto("/");
await admin.waitForShell();
// Call logout via API (POST with required headers)
await admin.page.evaluate(async () => {
await fetch("/_emdash/api/auth/logout", {
method: "POST",
headers: { "X-EmDash-Request": "1" },
});
});
// Try to access admin again
await admin.page.goto("/_emdash/admin/");
await admin.page.waitForURL(LOGIN_OR_ADMIN_URL_PATTERN, { timeout: 10000 });
// Should redirect to login
await expect(admin.page).toHaveURL(LOGIN_URL_PATTERN);
});
});
test.describe("User Menu", () => {
test("shows user menu in header", async ({ admin }) => {
await admin.devBypassAuth();
await admin.goto("/");
await admin.waitForShell();
// Click the user menu trigger (shows "Dev Admin" text)
await admin.page.getByText("Dev Admin").click();
// Should show menu options
await expect(admin.page.locator("text=Log out")).toBeVisible();
await expect(admin.page.locator("text=Security Settings")).toBeVisible();
await expect(admin.page.locator("text=Settings").last()).toBeVisible();
});
test("security settings link navigates correctly", async ({ admin }) => {
await admin.devBypassAuth();
await admin.goto("/");
await admin.waitForShell();
// Open user menu
await admin.page.getByText("Dev Admin").click();
// Click security settings (if present in menu)
const securityLink = admin.page.getByRole("menuitem", { name: SECURITY_MENUITEM_REGEX });
if (await securityLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await securityLink.click();
await expect(admin.page).toHaveURL(SECURITY_SETTINGS_URL_PATTERN);
}
});
});
});
test.describe("User Management", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("users page shows user list", async ({ admin }) => {
await admin.goto("/users");
await admin.waitForShell();
await admin.waitForLoading();
// Should show users page
await admin.expectPageTitle("Users");
// Should show at least the dev user
await expect(admin.page.locator("text=dev@emdash.local")).toBeVisible();
});
test("shows invite user button", async ({ admin }) => {
await admin.goto("/users");
await admin.waitForShell();
// Should have invite button
await expect(admin.page.locator('button:has-text("Invite User")')).toBeVisible();
});
test("invite user modal opens and closes", async ({ admin }) => {
await admin.goto("/users");
await admin.waitForShell();
// Click invite button
await admin.page.locator('button:has-text("Invite User")').click();
// Should show modal
await expect(admin.page.locator('input[type="email"]')).toBeVisible();
await expect(admin.page.locator('button:has-text("Send Invite")')).toBeVisible();
// Close modal
await admin.page.keyboard.press("Escape");
// Modal should close
await expect(admin.page.locator('[role="dialog"]')).not.toBeVisible();
});
test("can click user to see details", async ({ admin }) => {
await admin.goto("/users");
await admin.waitForShell();
await admin.waitForLoading();
// Click on user email link
await admin.page.locator("text=dev@emdash.local").first().click();
// Should show detail panel with user info
await expect(admin.page.locator("text=Dev Admin").first()).toBeVisible();
});
});
test.describe("Security Settings", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("shows security settings page", async ({ admin }) => {
await admin.goto("/settings/security");
await admin.waitForShell();
await admin.waitForLoading();
await expect(admin.page.locator("text=Passkeys").first()).toBeVisible();
});
test("shows add passkey button", async ({ admin }) => {
await admin.goto("/settings/security");
await admin.waitForShell();
await admin.waitForLoading();
await expect(admin.page.getByRole("button", { name: ADD_PASSKEY_REGEX })).toBeVisible();
});
});
test.describe("Signup Page", () => {
test("displays signup page", async ({ admin }) => {
// Navigate directly (not through admin which has auth)
await admin.page.goto("/_emdash/admin/signup");
// Wait for the React app to hydrate and render a heading with sign-related content.
// The SPA may render the login page if signup is disabled, so accept either.
await expect(
admin.page.getByRole("heading", { level: 1, name: SIGN_HEADING_REGEX }),
).toBeVisible({
timeout: 15000,
});
});
});

130
e2e/tests/autosave.spec.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Autosave E2E Tests
*
* Tests that autosave updates the existing draft revision in place
* rather than creating a new revision on each keystroke.
*
* Covers issue #5: skipRevision was stripped by Zod validation,
* causing every autosave to create a new revision.
*/
import { test, expect } from "../fixtures";
test.describe("Autosave", () => {
let collectionSlug: string;
let postId: string;
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${serverInfo.token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
// Create a collection with revision support
collectionSlug = `autosave_${Date.now()}`;
await fetch(`${baseUrl}/_emdash/api/schema/collections`, {
method: "POST",
headers,
body: JSON.stringify({
slug: collectionSlug,
label: "Autosave Test",
labelSingular: "Autosave Test",
supports: ["revisions", "drafts"],
}),
});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers,
body: JSON.stringify({ slug: "title", type: "string", label: "Title", required: true }),
});
// Create and publish a post
const createRes = await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}`, {
method: "POST",
headers,
body: JSON.stringify({ data: { title: "Original" }, slug: "autosave-test" }),
});
const createData: any = await createRes.json();
postId = createData.data?.item?.id ?? createData.data?.id;
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
});
test.afterEach(async () => {
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "DELETE",
headers,
}).catch(() => {});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
test("multiple autosaves update draft in place instead of creating new revisions", async ({
admin,
}) => {
const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`;
const isPut = (res: any) => res.url().includes(contentUrl) && res.request().method() === "PUT";
const isGet = (res: any) =>
res.url().includes(contentUrl) &&
!res.url().includes("/revisions") &&
res.request().method() === "GET";
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
const titleInput = admin.page.locator("#field-title");
await expect(titleInput).toHaveValue("Original");
// First edit — listen for both the PUT and the subsequent cache re-fetch GET
const firstPut = admin.page.waitForResponse(isPut, { timeout: 10000 });
await titleInput.fill("Edit One");
await firstPut;
// Wait for the cache invalidation GET to settle so form doesn't get overwritten
const refetchGet = admin.page.waitForResponse(isGet, { timeout: 5000 }).catch(() => {});
await refetchGet;
// Extra settle time for React state updates
await admin.page.waitForTimeout(500);
// Check revision count after first autosave
const res1 = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const data1: any = await res1.json();
const countAfterFirst = data1.data.total;
// Second edit — set up listener BEFORE typing
const secondPut = admin.page.waitForResponse(isPut, { timeout: 10000 });
await titleInput.fill("Edit Two");
await secondPut;
// Check revision count — should be same (updated in place, not new revision)
const res2 = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const data2: any = await res2.json();
const countAfterSecond = data2.data.total;
expect(countAfterSecond).toBe(countAfterFirst);
// Verify the latest revision contains the last autosaved data
const latestRevision = data2.data.items?.[0];
expect(latestRevision?.data?.title).toBe("Edit Two");
});
});

143
e2e/tests/bylines.spec.ts Normal file
View File

@@ -0,0 +1,143 @@
import { test, expect } from "../fixtures";
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+$/;
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
test.describe("Bylines", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("creates and edits a guest byline in admin", async ({ admin, page }) => {
const unique = Date.now();
const initialName = `Guest Byline ${unique}`;
const updatedName = `Guest Byline Updated ${unique}`;
await admin.goto("/bylines");
await admin.waitForShell();
await admin.waitForLoading();
await page.getByRole("button", { name: "New" }).click();
await page.getByLabel("Display name").fill(initialName);
await page.getByLabel("Slug").fill(`guest-byline-${unique}`);
await page.getByRole("switch", { name: "Guest byline" }).click();
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("button", { name: initialName })).toBeVisible({ timeout: 5000 });
await page.getByRole("button", { name: initialName }).click();
await page.getByLabel("Display name").fill(updatedName);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("button", { name: updatedName })).toBeVisible({ timeout: 5000 });
});
test("assigns and reorders bylines, preserves bylines on ownership change", async ({
admin,
page,
serverInfo,
}) => {
const unique = Date.now();
const primaryName = `Primary Writer ${unique}`;
const secondaryName = `Secondary Writer ${unique}`;
const headers = apiHeaders(serverInfo.token, serverInfo.baseUrl);
const createByline = async (displayName: string, slug: string) => {
const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/admin/bylines`, {
method: "POST",
headers,
body: JSON.stringify({
displayName,
slug,
isGuest: true,
}),
});
expect(response.ok).toBe(true);
const body: any = await response.json();
return body.data.id as string;
};
const firstBylineId = await createByline(primaryName, `primary-writer-${unique}`);
const secondBylineId = await createByline(secondaryName, `secondary-writer-${unique}`);
await admin.goToNewContent("posts");
await admin.waitForLoading();
await admin.fillField("title", `Byline E2E Post ${unique}`);
await admin.clickSave();
await expect(page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000 });
const contentId = page.url().split("/").pop();
expect(contentId).toBeTruthy();
await admin.waitForLoading();
// Scope the byline select to the Bylines section to avoid hitting the Ownership combobox
const bylinesSidebar = page
.getByRole("heading", { name: "Bylines" })
.locator("xpath=ancestor::div[contains(@class,'p-4')]")
.first();
const bylineSelect = bylinesSidebar.locator("select").first();
await bylineSelect.selectOption({ value: firstBylineId });
await bylinesSidebar.getByRole("button", { name: "Add" }).click();
await bylineSelect.selectOption({ value: secondBylineId });
await bylinesSidebar.getByRole("button", { name: "Add" }).click();
await page.getByLabel("Role label").nth(1).fill("Co-author");
await page.getByRole("button", { name: "Up" }).nth(1).click();
await admin.clickSave();
await admin.waitForSaveComplete();
await expect(bylinesSidebar.locator("p.text-sm.font-medium").first()).toContainText(
secondaryName,
);
const ownershipUpdateResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/content/posts/${contentId as string}`,
{
method: "PUT",
headers,
body: JSON.stringify({ authorId: null }),
},
);
expect(ownershipUpdateResponse.ok).toBe(true);
await page.reload();
await admin.waitForShell();
await admin.waitForLoading();
const bylineSectionAfterReload = page
.getByRole("heading", { name: "Bylines" })
.locator("xpath=ancestor::div[contains(@class,'p-4')]")
.first();
await expect(bylineSectionAfterReload.locator("p.text-sm.font-medium").first()).toContainText(
secondaryName,
);
const contentResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/content/posts/${contentId as string}`,
{ headers },
);
expect(contentResponse.ok).toBe(true);
const contentBody: any = await contentResponse.json();
const item = contentBody.data?.item;
expect(item.byline?.displayName).toBe(secondaryName);
expect(item.bylines).toHaveLength(2);
expect(item.bylines[0]?.byline?.displayName).toBe(secondaryName);
expect(item.bylines[1]?.byline?.displayName).toBe(primaryName);
const secondaryCredit = item.bylines.find(
(credit: any) => credit?.byline?.displayName === secondaryName,
);
expect(secondaryCredit?.roleLabel).toBe("Co-author");
});
});

287
e2e/tests/comments.spec.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* Comments Moderation E2E Tests
*
* Tests the admin comment moderation inbox at /comments.
* Seeds comments via the public API, then exercises the moderation UI:
* page rendering, empty state, comment list, approve, and delete.
*/
import { test, expect } from "../fixtures";
// Regex patterns (e18e/prefer-static-regex)
const PENDING_EMPTY_PATTERN = /no comments awaiting moderation/i;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function apiHeaders(token: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
};
}
/** Seed a comment via the public API and return the response body. */
async function seedComment(
page: import("@playwright/test").Page,
baseUrl: string,
token: string,
postId: string,
overrides: { body?: string; authorName?: string; authorEmail?: string } = {},
) {
const res = await page.request.post(`${baseUrl}/_emdash/api/comments/posts/${postId}`, {
headers: apiHeaders(token),
data: {
body: overrides.body ?? "Test comment from E2E",
authorName: overrides.authorName ?? "E2E Tester",
authorEmail: overrides.authorEmail ?? "e2e@test.com",
},
});
return res;
}
/** Delete all comments currently in the admin inbox (best-effort cleanup). */
async function cleanupComments(
page: import("@playwright/test").Page,
baseUrl: string,
token: string,
) {
const headers = apiHeaders(token);
for (const status of ["pending", "approved", "spam", "trash"] as const) {
const res = await page.request.fetch(
`${baseUrl}/_emdash/api/admin/comments?status=${status}&limit=100`,
{ headers },
);
if (!res.ok()) continue;
const data: { data?: { items?: { id: string }[] } } = await res.json().catch(() => ({}));
const ids = data?.data?.items?.map((c) => c.id) ?? [];
if (ids.length === 0) continue;
await page.request
.post(`${baseUrl}/_emdash/api/admin/comments/bulk`, {
headers,
data: { ids, action: "delete" },
})
.catch(() => {});
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Comments Moderation", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("page renders with correct title", async ({ admin }) => {
await admin.goto("/comments");
await admin.waitForShell();
await admin.waitForLoading();
await admin.expectPageTitle("Comments");
});
test("shows empty state when no comments exist", async ({ admin, page, serverInfo }) => {
// Clean up any existing comments first
await cleanupComments(page, serverInfo.baseUrl, serverInfo.token);
await admin.goto("/comments");
await admin.waitForShell();
await admin.waitForLoading();
// The "Pending" tab is active by default -- should show empty message
await expect(page.locator("td").filter({ hasText: PENDING_EMPTY_PATTERN })).toBeVisible({
timeout: 10000,
});
});
test("displays seeded comments with author and body", async ({ admin, page, serverInfo }) => {
const postId = serverInfo.contentIds.posts[0]!;
// Clean slate
await cleanupComments(page, serverInfo.baseUrl, serverInfo.token);
// Seed two comments
await seedComment(page, serverInfo.baseUrl, serverInfo.token, postId, {
body: "First seeded comment",
authorName: "Alice Commenter",
authorEmail: "alice@test.com",
});
await seedComment(page, serverInfo.baseUrl, serverInfo.token, postId, {
body: "Second seeded comment",
authorName: "Bob Commenter",
authorEmail: "bob@test.com",
});
await admin.goto("/comments");
await admin.waitForShell();
await admin.waitForLoading();
// Comments land as "pending" by default (moderation: first_time or all).
// The Pending tab is the default view, so we should see them.
// If the collection auto-approves, check the Approved tab instead.
const approvedTab = page.locator('[role="tab"]', { hasText: "Approved" });
// Try pending first -- if empty, check approved
let foundAlice = await page
.locator("text=Alice Commenter")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!foundAlice) {
// Comments may have been auto-approved
await approvedTab.click();
await admin.waitForLoading();
foundAlice = await page
.locator("text=Alice Commenter")
.isVisible({ timeout: 5000 })
.catch(() => false);
}
// At least one comment should be visible with author name and body
expect(foundAlice).toBe(true);
await expect(page.locator("text=First seeded comment").first()).toBeVisible();
await expect(page.locator("text=Bob Commenter").first()).toBeVisible();
await expect(page.locator("text=Second seeded comment").first()).toBeVisible();
});
test("approve a pending comment", async ({ admin, page, serverInfo }) => {
const postId = serverInfo.contentIds.posts[0]!;
// Clean slate
await cleanupComments(page, serverInfo.baseUrl, serverInfo.token);
// Seed a comment
await seedComment(page, serverInfo.baseUrl, serverInfo.token, postId, {
body: "Comment to approve",
authorName: "Approval Tester",
authorEmail: "approve@test.com",
});
await admin.goto("/comments");
await admin.waitForShell();
await admin.waitForLoading();
// Ensure we're on the Pending tab
const pendingTab = page.locator('[role="tab"]', { hasText: "Pending" });
await pendingTab.click();
await admin.waitForLoading();
// If the comment was auto-approved, this test cannot proceed -- skip gracefully
const hasPendingComment = await page
.locator("text=Approval Tester")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!hasPendingComment) {
// Comment was auto-approved -- verify it's in the Approved tab instead
const approvedTab = page.locator('[role="tab"]', { hasText: "Approved" });
await approvedTab.click();
await admin.waitForLoading();
await expect(page.locator("text=Approval Tester").first()).toBeVisible({
timeout: 5000,
});
return;
}
// Find the comment row and click the Approve button
const row = page.locator("tr", { hasText: "Approval Tester" });
const approveBtn = row.locator('button[aria-label="Approve"]');
await expect(approveBtn).toBeVisible({ timeout: 5000 });
await approveBtn.click();
// Wait for the mutation to settle
await admin.waitForLoading();
// The comment should disappear from the Pending tab
await expect(page.locator("tr", { hasText: "Approval Tester" })).not.toBeVisible({
timeout: 10000,
});
// Verify it moved to the Approved tab
const approvedTab = page.locator('[role="tab"]', { hasText: "Approved" });
await approvedTab.click();
await admin.waitForLoading();
await expect(page.locator("text=Approval Tester").first()).toBeVisible({
timeout: 10000,
});
});
test("delete a comment permanently", async ({ admin, page, serverInfo }) => {
const postId = serverInfo.contentIds.posts[0]!;
// Clean slate
await cleanupComments(page, serverInfo.baseUrl, serverInfo.token);
// Seed a comment
await seedComment(page, serverInfo.baseUrl, serverInfo.token, postId, {
body: "Comment to delete",
authorName: "Delete Tester",
authorEmail: "delete@test.com",
});
await admin.goto("/comments");
await admin.waitForShell();
await admin.waitForLoading();
// Find the comment -- could be in Pending or Approved
let commentVisible = await page
.locator("text=Delete Tester")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!commentVisible) {
const approvedTab = page.locator('[role="tab"]', { hasText: "Approved" });
await approvedTab.click();
await admin.waitForLoading();
commentVisible = await page
.locator("text=Delete Tester")
.isVisible({ timeout: 5000 })
.catch(() => false);
}
expect(commentVisible).toBe(true);
// The admin user should see the "Delete permanently" button
const row = page.locator("tr", { hasText: "Delete Tester" });
const deleteBtn = row.locator('button[aria-label="Delete permanently"]');
const hasDeleteBtn = await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (!hasDeleteBtn) {
// Fallback: try the "Trash" button instead (non-admin role)
const trashBtn = row.locator('button[aria-label="Trash"]');
await expect(trashBtn).toBeVisible({ timeout: 3000 });
await trashBtn.click();
await admin.waitForLoading();
// Comment should disappear from the current tab
await expect(row).not.toBeVisible({ timeout: 10000 });
return;
}
// Click "Delete permanently" -- this opens a ConfirmDialog
await deleteBtn.click();
// Confirm deletion in the dialog
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.locator("text=Delete Comment")).toBeVisible();
await dialog.getByRole("button", { name: "Delete" }).click();
// Wait for dialog to close and comment to disappear
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await admin.waitForLoading();
// Comment should be gone from all tabs
await expect(page.locator("text=Delete Tester")).not.toBeVisible({ timeout: 10000 });
});
});

View File

@@ -0,0 +1,572 @@
/**
* Content Actions E2E Tests
*
* Tests content lifecycle actions that go beyond basic CRUD:
* - Schedule / unschedule for future publishing
* - Duplicate content
* - Soft delete (trash) and restore from trash
* - Permanent delete
* - Discard draft changes (revert to published version)
*
* Uses the seeded "posts" collection which supports drafts and revisions.
*/
import { test, expect } from "../fixtures";
// ---------- regex patterns ----------
const SCHEDULE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/schedule/;
const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/duplicate/;
const DISCARD_DRAFT_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/discard-draft/;
const RESTORE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/restore/;
const PERMANENT_DELETE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/permanent/;
// Button/tab label patterns
const TRASH_TAB_LABEL = /Trash/i;
const RESTORE_LABEL = /Restore/i;
const PERM_DELETE_LABEL = /Permanently delete/i;
// ---------- helpers ----------
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
/** Create a post via API and return its ID */
async function createPost(
baseUrl: string,
headers: Record<string, string>,
title: string,
slug: string,
): Promise<string> {
const res = await fetch(`${baseUrl}/_emdash/api/content/posts`, {
method: "POST",
headers,
body: JSON.stringify({ data: { title }, slug }),
});
const json: any = await res.json();
return json.data?.item?.id ?? json.data?.id;
}
/** Publish a post via API */
async function publishPost(
baseUrl: string,
headers: Record<string, string>,
id: string,
): Promise<void> {
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
}
/** Soft-delete a post via API (move to trash) */
async function trashPost(
baseUrl: string,
headers: Record<string, string>,
id: string,
): Promise<void> {
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
method: "DELETE",
headers,
});
}
/** Clean up a post — trash then permanently delete, ignoring errors */
async function cleanupPost(
baseUrl: string,
headers: Record<string, string>,
id: string,
): Promise<void> {
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
method: "DELETE",
headers,
}).catch(() => {});
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/permanent`, {
method: "DELETE",
headers,
}).catch(() => {});
}
// ==========================================================================
// Schedule / Unschedule
// ==========================================================================
test.describe("Schedule content", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Create a fresh draft post for scheduling tests
postId = await createPost(
baseUrl,
headers,
"Schedule Test Post",
`schedule-test-${Date.now()}`,
);
});
test.afterEach(async () => {
await cleanupPost(baseUrl, headers, postId);
});
test("schedule a draft post for future publishing", async ({ admin, page }) => {
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();
// Verify we're on the edit page with our post
await expect(page.locator("#field-title")).toHaveValue("Schedule Test Post");
// The "Schedule for later" button should be visible in the sidebar
const scheduleButton = page.getByRole("button", { name: "Schedule for later" });
await expect(scheduleButton).toBeVisible({ timeout: 5000 });
await scheduleButton.click();
// A datetime input should appear
const dateInput = page.getByLabel("Schedule for");
await expect(dateInput).toBeVisible({ timeout: 5000 });
// Set a future date (tomorrow)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const dateValue = tomorrow.toISOString().slice(0, 16); // datetime-local format
await dateInput.fill(dateValue);
// Click the "Schedule" confirm button and wait for the API response
const scheduleResponse = page.waitForResponse(
(res) =>
SCHEDULE_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 10000 },
);
await page.getByRole("button", { name: "Schedule", exact: true }).click();
await scheduleResponse;
// A toast confirming scheduling should appear
await expect(page.getByRole("heading", { name: "Scheduled" })).toBeVisible({ timeout: 5000 });
// The scheduled date info should be visible
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
// An "Unschedule" button should be visible
await expect(page.getByRole("button", { name: "Unschedule" })).toBeVisible();
});
test("unschedule a scheduled post", async ({ admin, page }) => {
// Schedule the post via API first
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}/schedule`, {
method: "POST",
headers,
body: JSON.stringify({ scheduledAt: tomorrow.toISOString() }),
});
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();
// Verify scheduled state is shown
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
// Click unschedule and wait for API response
const unscheduleResponse = page.waitForResponse(
(res) =>
SCHEDULE_API_PATTERN.test(res.url()) &&
res.request().method() === "DELETE" &&
res.status() === 200,
{ timeout: 10000 },
);
await page.getByRole("button", { name: "Unschedule" }).click();
await unscheduleResponse;
// The scheduled info should disappear
await expect(page.locator("text=Scheduled for:")).not.toBeVisible({ timeout: 5000 });
// The "Schedule for later" button should reappear
await expect(page.getByRole("button", { name: "Schedule for later" })).toBeVisible({
timeout: 5000,
});
});
});
// ==========================================================================
// Duplicate
// ==========================================================================
test.describe("Duplicate content", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
let duplicateId: string | undefined;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
postId = await createPost(
baseUrl,
headers,
"Duplicate Source Post",
`dup-source-${Date.now()}`,
);
});
test.afterEach(async () => {
await cleanupPost(baseUrl, headers, postId);
if (duplicateId) {
await cleanupPost(baseUrl, headers, duplicateId);
}
});
test("duplicate a post from the content list", async ({ admin, page }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Find the row for our post and click the duplicate button
const row = page.locator("tr", { hasText: "Duplicate Source Post" });
await expect(row).toBeVisible({ timeout: 5000 });
const duplicateResponse = page.waitForResponse(
(res) =>
DUPLICATE_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
(res.status() === 200 || res.status() === 201),
{ timeout: 10000 },
);
await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click();
const response = await duplicateResponse;
const body = await response.json();
duplicateId = body.data?.item?.id ?? body.data?.id;
// Wait for the list to refresh
await admin.waitForLoading();
// A copy should now appear in the list (typically with "(Copy)" suffix or similar)
// Reload to ensure fresh data
await page.reload();
await admin.waitForShell();
await admin.waitForLoading();
// The duplicate should exist -- verify via API since the title pattern may vary
expect(duplicateId).toBeTruthy();
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${duplicateId}`, {
headers,
});
expect(getRes.ok).toBe(true);
});
});
// ==========================================================================
// Trash (soft delete) and Restore
// ==========================================================================
test.describe("Trash and restore content", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
postId = await createPost(baseUrl, headers, "Trash Test Post", `trash-test-${Date.now()}`);
});
test.afterEach(async () => {
await cleanupPost(baseUrl, headers, postId);
});
test("move a post to trash from the content list", async ({ admin, page }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Find the row and click the trash button
const row = page.locator("tr", { hasText: "Trash Test Post" });
await expect(row).toBeVisible({ timeout: 5000 });
await row.getByRole("button", { name: "Move Trash Test Post to trash" }).click();
// A confirmation dialog should appear
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.locator("text=Trash Test Post")).toBeVisible();
// Confirm the deletion
const deleteResponse = page.waitForResponse(
(res) =>
res.url().includes(`/api/content/posts/${postId}`) &&
res.request().method() === "DELETE" &&
res.status() === 200,
{ timeout: 10000 },
);
await dialog.getByRole("button", { name: "Move to Trash" }).click();
await deleteResponse;
// The dialog should close
await expect(dialog).not.toBeVisible({ timeout: 5000 });
// The post should no longer appear in the "All" tab
await admin.waitForLoading();
await expect(page.locator("tr", { hasText: "Trash Test Post" })).not.toBeVisible({
timeout: 5000,
});
});
test("restore a trashed post from the trash tab", async ({ admin, page }) => {
// Trash the post via API first
await trashPost(baseUrl, headers, postId);
await admin.goToContent("posts");
await admin.waitForLoading();
// Switch to the Trash tab
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
await expect(trashTab).toBeVisible({ timeout: 5000 });
await trashTab.click();
// Wait for trashed items to load
await admin.waitForLoading();
// The trashed post should appear
const trashedRow = page.locator("tr", { hasText: "Trash Test Post" });
await expect(trashedRow).toBeVisible({ timeout: 10000 });
// Click the restore button
const restoreResponse = page.waitForResponse(
(res) =>
RESTORE_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 10000 },
);
await trashedRow.getByRole("button", { name: RESTORE_LABEL }).click();
await restoreResponse;
// Wait for the list to refresh
await admin.waitForLoading();
// Switch back to the All tab
const allTab = page.getByRole("tab", { name: "All" });
await allTab.click();
await admin.waitForLoading();
// The post should be back in the main list
await expect(page.locator("tr", { hasText: "Trash Test Post" })).toBeVisible({
timeout: 10000,
});
});
});
// ==========================================================================
// Permanent delete
// ==========================================================================
test.describe("Permanent delete", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
postId = await createPost(baseUrl, headers, "Permanent Delete Post", `perm-del-${Date.now()}`);
// Trash it first -- permanent delete only works on trashed items
await trashPost(baseUrl, headers, postId);
});
test("permanently delete a trashed post", async ({ admin, page }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Switch to the Trash tab
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
await trashTab.click();
await admin.waitForLoading();
// The trashed post should appear
const trashedRow = page.locator("tr", { hasText: "Permanent Delete Post" });
await expect(trashedRow).toBeVisible({ timeout: 10000 });
// Click the permanent delete button (trash icon in trash view)
await trashedRow.getByRole("button", { name: PERM_DELETE_LABEL }).click();
// A confirmation dialog should appear
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Delete Permanently?" });
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.locator("text=Permanent Delete Post")).toBeVisible();
// Confirm permanent deletion
const permanentDeleteResponse = page.waitForResponse(
(res) =>
PERMANENT_DELETE_API_PATTERN.test(res.url()) &&
res.request().method() === "DELETE" &&
res.status() === 200,
{ timeout: 10000 },
);
await dialog.getByRole("button", { name: "Delete Permanently" }).click();
await permanentDeleteResponse;
// The dialog should close
await expect(dialog).not.toBeVisible({ timeout: 5000 });
// The post should disappear from the trash
await admin.waitForLoading();
await expect(page.locator("tr", { hasText: "Permanent Delete Post" })).not.toBeVisible({
timeout: 5000,
});
// Verify via API that the post is truly gone
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
expect(getRes.status).toBe(404);
});
});
// ==========================================================================
// Discard draft changes
// ==========================================================================
test.describe("Discard draft changes", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Create and publish a post
postId = await createPost(
baseUrl,
headers,
"Published Original Title",
`discard-draft-${Date.now()}`,
);
await publishPost(baseUrl, headers, postId);
});
test.afterEach(async () => {
await cleanupPost(baseUrl, headers, postId);
});
test("discard draft reverts to the published version", async ({ admin, page }) => {
// Navigate to the editor and make changes to create a draft
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();
// Verify the published title
const titleInput = page.locator("#field-title");
await expect(titleInput).toHaveValue("Published Original Title");
// Edit the title
await titleInput.fill("Draft Modified Title");
// Save to create a draft revision
await admin.clickSave();
await admin.waitForSaveComplete();
// The "Pending changes" badge should appear
await expect(page.locator("text=Pending changes")).toBeVisible({ timeout: 5000 });
// The "Discard changes" button should be visible
const discardButton = page.getByRole("button", { name: "Discard changes" });
await expect(discardButton).toBeVisible({ timeout: 5000 });
await discardButton.click();
// A confirmation dialog should appear
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Discard draft changes?" });
await expect(dialog).toBeVisible({ timeout: 5000 });
// Confirm discarding the draft
const discardResponse = page.waitForResponse(
(res) =>
DISCARD_DRAFT_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 10000 },
);
await dialog.getByRole("button", { name: "Discard changes" }).click();
await discardResponse;
// Wait for the page to update
await admin.waitForLoading();
// The title should revert to the published version
await expect(titleInput).toHaveValue("Published Original Title", { timeout: 10000 });
// The "Pending changes" badge should be gone
await expect(page.locator("text=Pending changes")).not.toBeVisible({ timeout: 5000 });
// The "Discard changes" button should also be gone
await expect(discardButton).not.toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// Trash from the editor
// ==========================================================================
test.describe("Trash from editor", () => {
let headers: Record<string, string>;
let baseUrl: string;
let postId: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
postId = await createPost(baseUrl, headers, "Editor Trash Post", `editor-trash-${Date.now()}`);
});
test.afterEach(async () => {
await cleanupPost(baseUrl, headers, postId);
});
test("move a post to trash from the editor sidebar", async ({ admin, page }) => {
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();
// The "Move to Trash" button should be in the sidebar
const trashButton = page.getByRole("button", { name: "Move to Trash" });
await expect(trashButton).toBeVisible({ timeout: 5000 });
await trashButton.click();
// A confirmation dialog should appear
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
await expect(dialog).toBeVisible({ timeout: 5000 });
// Confirm trashing
const deleteResponse = page.waitForResponse(
(res) =>
res.url().includes(`/api/content/posts/${postId}`) &&
res.request().method() === "DELETE" &&
res.status() === 200,
{ timeout: 10000 },
);
await dialog.getByRole("button", { name: "Move to Trash" }).click();
await deleteResponse;
// Should navigate back to the content list (or show a confirmation)
// Verify the post is trashed via API
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
expect(getRes.status).toBe(404);
});
});

View File

@@ -0,0 +1,164 @@
/**
* Content CRUD E2E Tests
*
* Tests creating, reading, updating, and deleting content items.
* Runs against an isolated fixture with seeded posts and pages.
*
* Seed data:
* - posts: "First Post" (published), "Second Post" (published), "Draft Post" (draft)
* - pages: "About" (published), "Contact" (draft)
*/
import { test, expect } from "../fixtures";
// Regex patterns
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+$/;
const CONTENT_ID_PATTERN = /\/content\/posts\/[A-Z0-9]+$/;
const NEW_CONTENT_URL_PATTERN = /\/content\/posts\/new$/;
test.describe("Content CRUD", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Content List", () => {
test("displays content list with seeded items", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Should show the posts heading
await admin.expectPageTitle("Posts");
// Should have a table with content
await expect(admin.page.locator("table")).toBeVisible();
// Should show seeded posts
await expect(admin.page.getByRole("link", { name: "First Post", exact: true })).toBeVisible();
await expect(
admin.page.getByRole("link", { name: "Second Post", exact: true }),
).toBeVisible();
await expect(admin.page.getByRole("link", { name: "Draft Post", exact: true })).toBeVisible();
// Should have "Add New" link
await expect(admin.page.getByRole("link", { name: "Add New" })).toBeVisible();
});
test("clicking Add New navigates to content editor", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Click Add New
await admin.page.getByRole("link", { name: "Add New" }).click();
// Should navigate to new content page
await expect(admin.page).toHaveURL(NEW_CONTENT_URL_PATTERN, {
timeout: 10000,
});
});
});
test.describe("Create Content", () => {
test("creates new post with title", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// Fill in title
await admin.fillField("title", "E2E Test Post");
// Save
await admin.clickSave();
// Should redirect to edit page with new ID (ULID)
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
});
test("auto-generates slug from title", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// Fill in title — slug should auto-generate
await admin.fillField("title", "My Amazing Blog Post");
// Check that slug field was auto-populated
const slugInput = admin.page.getByLabel("Slug");
await expect(slugInput).toHaveValue("my-amazing-blog-post");
});
});
test.describe("Edit Content", () => {
test("loads existing content for editing", async ({ admin }) => {
// Go to content list
await admin.goToContent("posts");
await admin.waitForLoading();
// Click on first content item to edit
await admin.page.getByRole("link", { name: "First Post", exact: true }).click();
// Should be on edit page
await expect(admin.page).toHaveURL(CONTENT_ID_PATTERN);
// Title field should be populated
await expect(admin.page.locator("#field-title")).toHaveValue("First Post");
});
test("saves updated content", async ({ admin }) => {
// Navigate to existing content
await admin.goToContent("posts");
await admin.waitForLoading();
// Click first item to edit
await admin.page.getByRole("link", { name: "First Post", exact: true }).click();
await admin.waitForLoading();
// Update title
const newTitle = `Updated Post ${Date.now()}`;
await admin.fillField("title", newTitle);
// Save
await admin.clickSave();
await admin.waitForSaveComplete();
// Verify the update persisted by reloading
await admin.page.reload();
await admin.waitForShell();
await admin.waitForLoading();
await expect(admin.page.locator("#field-title")).toHaveValue(newTitle);
});
});
test.describe("Content Status", () => {
test("displays content status badges", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Should show status badges (published and draft)
const statusBadges = admin.page.locator("span.inline-flex");
const count = await statusBadges.count();
expect(count).toBeGreaterThan(0);
});
test("publish action changes status", async ({ admin }) => {
// Create a new draft post first
await admin.goToNewContent("posts");
await admin.waitForLoading();
await admin.fillField("title", "Draft to Publish Test");
await admin.clickSave();
// Wait for redirect to edit page (confirms save succeeded)
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
// Look for publish button
const publishButton = admin.page.getByRole("button", { name: "Publish" });
if (await publishButton.isVisible()) {
await publishButton.click();
await admin.waitForLoading();
}
});
});
});

View File

@@ -0,0 +1,225 @@
/**
* Content Types / Schema Editor E2E Tests
*
* Tests listing, viewing, creating, editing fields, and deleting content types.
* Runs against an isolated fixture with seeded posts and pages collections.
*
* Seed data (from fixture/.emdash/seed.json):
* - posts: title (string, required), body (portableText), excerpt (text), theme_color (string)
* - pages: title (string, required), body (portableText)
*/
import { test, expect } from "../fixtures";
// Regex patterns (module scope per lint rules)
const CONTENT_TYPES_SLUG_PATTERN = /\/content-types\/posts$/;
// Fixed test slug -- sequential tests (workers: 1) share state.
// Use a fixed value so cross-test dependencies work reliably.
const TEST_SLUG = "e2e_test_articles";
const TEST_LABEL_SINGULAR = "Article";
const TEST_LABEL_PLURAL = `${TEST_LABEL_SINGULAR}s`;
test.describe("Content Types", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Content Types List", () => {
test("displays seeded collections in a table", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
// Page title
await admin.expectPageTitle("Content Types");
// Should show the table
await expect(admin.page.locator("table")).toBeVisible();
// Seeded collections should appear as links in the table (scope to table to avoid sidebar)
const table = admin.page.locator("table");
await expect(table.getByRole("link", { name: "Posts", exact: true })).toBeVisible();
await expect(table.getByRole("link", { name: "Pages", exact: true })).toBeVisible();
});
test("shows slug column for each collection", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
// Slug values rendered as <code> elements inside the table
await expect(admin.page.locator("table code", { hasText: "posts" })).toBeVisible();
await expect(admin.page.locator("table code", { hasText: "pages" })).toBeVisible();
});
test("has a New Content Type button", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
await expect(admin.page.getByRole("link", { name: "New Content Type" })).toBeVisible();
});
});
test.describe("View Content Type", () => {
test("clicking a collection shows its field list", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
// Click into the posts collection (scope to table to avoid sidebar link)
await admin.page.locator("table").getByRole("link", { name: "Posts", exact: true }).click();
// Should navigate to the editor page
await expect(admin.page).toHaveURL(CONTENT_TYPES_SLUG_PATTERN, {
timeout: 10000,
});
// Page heading should show the collection label
await admin.expectPageTitle("Posts");
// Should show system fields section
await expect(admin.page.getByText("System Fields")).toBeVisible();
// Should show custom fields section
await expect(admin.page.getByText("Custom Fields", { exact: true })).toBeVisible();
});
test("shows expected custom fields for posts", async ({ admin }) => {
await admin.goto("/content-types/posts");
await admin.waitForShell();
await admin.waitForLoading();
// Custom field labels
await expect(admin.page.getByText("Title").first()).toBeVisible();
await expect(admin.page.getByText("Body").first()).toBeVisible();
await expect(admin.page.getByText("Excerpt").first()).toBeVisible();
// Field slugs rendered as <code> elements
await expect(admin.page.locator("code", { hasText: "title" }).first()).toBeVisible();
await expect(admin.page.locator("code", { hasText: "body" }).first()).toBeVisible();
await expect(admin.page.locator("code", { hasText: "excerpt" }).first()).toBeVisible();
});
test("shows system fields for a collection", async ({ admin }) => {
await admin.goto("/content-types/posts");
await admin.waitForShell();
await admin.waitForLoading();
// System field slugs
for (const slug of ["id", "slug", "status", "created_at", "updated_at", "published_at"]) {
await expect(admin.page.locator("code", { hasText: slug }).first()).toBeVisible();
}
});
});
test.describe("Create Content Type", () => {
test("creates a new content type and redirects to editor", async ({ admin }) => {
await admin.goto("/content-types/new");
await admin.waitForShell();
await admin.waitForLoading();
// Page heading
await admin.expectPageTitle("New Content Type");
// Fill in the singular label -- this auto-generates plural label and slug
const singularInput = admin.page.getByLabel("Label (Singular)");
await singularInput.fill(TEST_LABEL_SINGULAR);
// Verify auto-generated plural label
const pluralInput = admin.page.getByLabel("Label (Plural)");
await expect(pluralInput).toHaveValue(TEST_LABEL_PLURAL);
// Override slug with our unique test slug
const slugInput = admin.page.getByLabel("Slug");
await slugInput.fill(TEST_SLUG);
// Submit
await admin.page.getByRole("button", { name: "Create Content Type" }).click();
// Should redirect to the new collection's editor page
await expect(admin.page).toHaveURL(new RegExp(`/content-types/${TEST_SLUG}$`), {
timeout: 15000,
});
// Heading should show the plural label
await admin.expectPageTitle(TEST_LABEL_PLURAL);
});
test("new collection appears in the content types list", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
// The collection we created in the previous test should appear (scope to table)
await expect(
admin.page.locator("table").getByRole("link", { name: TEST_LABEL_PLURAL, exact: true }),
).toBeVisible({ timeout: 10000 });
await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).toBeVisible();
});
});
test.describe("Add Field to Content Type", () => {
test("adds a text field to the test collection", async ({ admin }) => {
await admin.goto(`/content-types/${TEST_SLUG}`);
await admin.waitForShell();
await admin.waitForLoading();
// Wait for the collection editor to fully load
await admin.expectPageTitle(TEST_LABEL_PLURAL);
// Click "Add Field" button
await admin.page.getByRole("button", { name: "Add Field" }).first().click();
// The field editor dialog should open -- first step is type selection
const dialog = admin.page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Select "Short Text" field type
await dialog.getByText("Short Text").click();
// Now on the config step -- fill in label (slug auto-generates)
await dialog.getByLabel("Label").fill("Summary");
// Verify slug was auto-generated
await expect(dialog.getByLabel("Slug")).toHaveValue("summary");
// Click save
await dialog.getByRole("button", { name: "Add Field" }).click();
// Dialog should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// The new field should appear in the field list
await admin.waitForLoading();
await expect(admin.page.getByText("Summary", { exact: true })).toBeVisible({
timeout: 10000,
});
await expect(admin.page.locator("code", { hasText: "summary" })).toBeVisible();
});
});
test.describe("Delete Content Type", () => {
test("deletes the test-created collection", async ({ admin }) => {
await admin.goto("/content-types");
await admin.waitForLoading();
// Verify the test collection exists before deletion
await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).toBeVisible({
timeout: 10000,
});
// Find the row for the test collection and click its delete button
const row = admin.page.locator("tr").filter({ hasText: TEST_LABEL_PLURAL });
await row.getByRole("button", { name: `Delete ${TEST_LABEL_PLURAL}` }).click();
// Confirm deletion in the dialog
const dialog = admin.page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByRole("button", { name: "Delete" }).click();
// Collection should disappear from the list
await admin.waitForLoading();
await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).not.toBeVisible({
timeout: 10000,
});
});
});
});

View File

@@ -0,0 +1,143 @@
/**
* Device Authorization E2E Tests
*
* Tests the device authorization page at /device. This is a standalone page
* (no Shell wrapper) used for OAuth device flow -- the user enters a code
* shown by `emdash login` in their terminal.
*
* The page checks authentication and redirects to login if not authenticated.
* For these tests we bypass auth first, then navigate directly.
*/
import { test, expect } from "../fixtures";
// Regex patterns
const DEVICE_AUTHORIZE_PATTERN = /\/api\/oauth\/device\/authorize$/;
test.describe("Device Authorization", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Page rendering", () => {
test("renders the device authorization page with heading", async ({ admin }) => {
// Navigate directly -- device page is standalone (no Shell)
await admin.page.goto("/_emdash/admin/device");
await admin.waitForHydration();
// Page heading
await expect(admin.page.locator("h1")).toContainText("Authorize Device", {
timeout: 15000,
});
// Subtitle
await expect(admin.page.locator("text=Enter the code from your terminal")).toBeVisible();
});
test("shows user info badge for authenticated user", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device");
// Wait for the auth check to complete (shows "Checking authentication..." first)
// then the user badge appears with role info.
await expect(admin.page.getByText("Dev Admin")).toBeVisible({ timeout: 15000 });
await expect(admin.page.getByText("Admin", { exact: true })).toBeVisible();
});
});
test.describe("Code input", () => {
test("shows device code input field", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device");
await admin.waitForHydration();
// Wait for the input to appear (after auth check completes)
const codeInput = admin.page.locator("#user-code");
await expect(codeInput).toBeVisible({ timeout: 15000 });
// Input should have the expected placeholder
await expect(codeInput).toHaveAttribute("placeholder", "XXXX-XXXX");
});
test("Authorize button is disabled until 8 characters are entered", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device");
await admin.waitForHydration();
const codeInput = admin.page.locator("#user-code");
await expect(codeInput).toBeVisible({ timeout: 15000 });
// Both buttons should be disabled with empty input
const authorizeBtn = admin.page.getByRole("button", { name: "Authorize" });
const denyBtn = admin.page.getByRole("button", { name: "Deny" });
await expect(authorizeBtn).toBeDisabled();
await expect(denyBtn).toBeDisabled();
// Type a partial code (less than 8 chars) -- still disabled
await codeInput.fill("ABCD");
await expect(authorizeBtn).toBeDisabled();
// Type a full 8-character code -- buttons should enable
await codeInput.fill("ABCD-1234");
await expect(authorizeBtn).toBeEnabled();
await expect(denyBtn).toBeEnabled();
});
test("auto-formats code with hyphen after 4 characters", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device");
await admin.waitForHydration();
const codeInput = admin.page.locator("#user-code");
await expect(codeInput).toBeVisible({ timeout: 15000 });
// Type 5 characters without hyphen -- should auto-insert
await codeInput.pressSequentially("ABCDE");
// Value should be "ABCD-E" (hyphen auto-inserted after 4th char)
await expect(codeInput).toHaveValue("ABCD-E");
});
});
test.describe("Invalid code submission", () => {
test("submitting an invalid code shows error", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device");
await admin.waitForHydration();
const codeInput = admin.page.locator("#user-code");
await expect(codeInput).toBeVisible({ timeout: 15000 });
// Enter a valid-format but non-existent code
await codeInput.fill("ZZZZ-9999");
// Submit the form
const authorizeBtn = admin.page.getByRole("button", { name: "Authorize" });
await expect(authorizeBtn).toBeEnabled();
// Wait for the API response (should be an error)
const authResponse = admin.page.waitForResponse(
(res) => DEVICE_AUTHORIZE_PATTERN.test(res.url()) && res.request().method() === "POST",
{ timeout: 15000 },
);
await authorizeBtn.click();
await authResponse;
// Error message should appear
await expect(
admin.page
.locator("text=Invalid or expired code")
.or(admin.page.locator(".text-destructive")),
).toBeVisible({ timeout: 10000 });
});
});
test.describe("URL pre-population", () => {
test("pre-populates code from URL query parameter", async ({ admin }) => {
await admin.page.goto("/_emdash/admin/device?code=TEST-CODE");
await admin.waitForHydration();
const codeInput = admin.page.locator("#user-code");
await expect(codeInput).toBeVisible({ timeout: 15000 });
// Should be pre-filled from the query param
await expect(codeInput).toHaveValue("TEST-CODE");
});
});
});

View File

@@ -0,0 +1,242 @@
/**
* Field Widget E2E Tests (Playwright)
*
* Tests plugin field widgets in the admin UI:
* - Color picker widget renders for fields with widget: "color:picker"
* - Widget is interactive (color input, hex input, presets)
* - Content saves and loads with widget field values
* - Widget falls back to default renderer when plugin is not active
* - Manifest includes widget metadata
*
* The e2e fixture has the color plugin configured with a "theme_color"
* field (type: string, widget: "color:picker") on the posts collection.
*/
import { test, expect } from "../fixtures";
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+$/;
test.describe("Field Widgets", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Color Picker Widget Rendering", () => {
test("renders color picker widget on new post form", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// The color picker widget should be visible (has data-testid)
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
// Should have a color input
const colorInput = admin.page.locator('[data-testid="color-input"]');
await expect(colorInput).toBeVisible();
await expect(colorInput).toHaveAttribute("type", "color");
// Should have a hex text input
const hexInput = admin.page.locator('[data-testid="color-hex-input"]');
await expect(hexInput).toBeVisible();
// Should have a preview swatch
const preview = admin.page.locator('[data-testid="color-preview"]');
await expect(preview).toBeVisible();
// Should have preset color buttons
const presets = admin.page.locator('[data-testid="color-presets"]');
await expect(presets).toBeVisible();
const presetButtons = presets.locator("button");
await expect(presetButtons).toHaveCount(10);
});
test("shows field label for color widget", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// The label "Theme Color" should be visible
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
await expect(widget.locator("label")).toContainText("Theme Color");
});
test("other fields render with default editors", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// Title field should use standard input (not a widget)
const titleInput = admin.page.locator("#field-title");
await expect(titleInput).toBeVisible();
// Should be a plain input, not a color picker widget
await expect(admin.page.locator('[data-testid="color-picker-widget"]')).toHaveCount(1);
await expect(
titleInput.locator("..").locator('[data-testid="color-picker-widget"]'),
).toHaveCount(0);
});
});
test.describe("Color Picker Interaction", () => {
test("can type a hex value", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
const hexInput = admin.page.locator('[data-testid="color-hex-input"]');
await expect(hexInput).toBeVisible({ timeout: 10000 });
await hexInput.fill("#ff6600");
await expect(hexInput).toHaveValue("#ff6600");
});
test("clicking a preset updates the value", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
// Click the red preset (#ef4444)
const redPreset = admin.page.locator('[data-testid="color-preset-ef4444"]');
await expect(redPreset).toBeVisible();
await redPreset.click();
// Hex input should update
const hexInput = admin.page.locator('[data-testid="color-hex-input"]');
await expect(hexInput).toHaveValue("#ef4444");
});
test("clicking different presets changes the value", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
const hexInput = admin.page.locator('[data-testid="color-hex-input"]');
// Click blue preset
await admin.page.locator('[data-testid="color-preset-3b82f6"]').click();
await expect(hexInput).toHaveValue("#3b82f6");
// Click green preset
await admin.page.locator('[data-testid="color-preset-22c55e"]').click();
await expect(hexInput).toHaveValue("#22c55e");
});
});
test.describe("Save and Load Widget Values", () => {
test("saves content with color value and loads it back", async ({ admin }) => {
// Create a post with a color
await admin.goToNewContent("posts");
await admin.waitForLoading();
// Wait for widget to render
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
// Fill title
await admin.fillField("title", "Color Widget Test Post");
// Set color via hex input
const hexInput = admin.page.locator('[data-testid="color-hex-input"]');
await hexInput.fill("#ff6600");
// Save
await admin.clickSave();
await admin.waitForSaveComplete();
// Should redirect to edit page
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
// Reload the page to verify the value persisted
await admin.page.reload();
await admin.waitForLoading();
// Wait for widget and check value
await expect(widget).toBeVisible({ timeout: 10000 });
const reloadedHex = admin.page.locator('[data-testid="color-hex-input"]');
await expect(reloadedHex).toHaveValue("#ff6600");
});
test("saves content with preset color value", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
await admin.fillField("title", "Preset Color Post");
// Click purple preset
await admin.page.locator('[data-testid="color-preset-8b5cf6"]').click();
await admin.clickSave();
await admin.waitForSaveComplete();
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
// Reload and verify
await admin.page.reload();
await admin.waitForLoading();
await expect(widget).toBeVisible({ timeout: 10000 });
await expect(admin.page.locator('[data-testid="color-hex-input"]')).toHaveValue("#8b5cf6");
});
test("saves content without color value", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
const widget = admin.page.locator('[data-testid="color-picker-widget"]');
await expect(widget).toBeVisible({ timeout: 10000 });
// Just set title, don't touch color
await admin.fillField("title", "No Color Post");
await admin.clickSave();
await admin.waitForSaveComplete();
// Should save successfully
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
});
});
test.describe("Manifest API", () => {
test("manifest includes widget property on theme_color field", async ({ page }) => {
const res = await page.request.get("/_emdash/api/manifest", {
headers: { "X-EmDash-Request": "1" },
});
expect(res.ok()).toBe(true);
const body = await res.json();
const manifest = body.data;
// Check field has widget
const postFields = manifest.collections.posts.fields;
expect(postFields.theme_color).toBeDefined();
expect(postFields.theme_color.widget).toBe("color:picker");
expect(postFields.theme_color.kind).toBe("string");
// Other fields should not have widget
expect(postFields.title.widget).toBeUndefined();
});
test("manifest includes color plugin with fieldWidgets", async ({ page }) => {
const res = await page.request.get("/_emdash/api/manifest", {
headers: { "X-EmDash-Request": "1" },
});
const body = await res.json();
const manifest = body.data;
// Check plugin manifest
expect(manifest.plugins.color).toBeDefined();
expect(manifest.plugins.color.enabled).toBe(true);
expect(manifest.plugins.color.fieldWidgets).toBeDefined();
expect(manifest.plugins.color.fieldWidgets).toHaveLength(1);
expect(manifest.plugins.color.fieldWidgets[0].name).toBe("picker");
expect(manifest.plugins.color.fieldWidgets[0].label).toBe("Color Picker");
expect(manifest.plugins.color.fieldWidgets[0].fieldTypes).toEqual(["string"]);
});
});
});

View File

@@ -0,0 +1,165 @@
/**
* Form Data Loss Prevention E2E Tests
*
* Verifies fixes from PR #133 — background refetches, false save states,
* and stale taxonomy selections no longer cause silent data loss.
*
* Seed data:
* - posts: "First Post" (published), with categories taxonomy
* - categories taxonomy: "News", "Tutorials", "Opinion"
* - sections: "Hero Section" (slug: hero)
*/
import { test, expect } from "../fixtures";
test.describe("Form Data Loss Prevention", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("settings edits survive window blur/focus", async ({ admin, page }) => {
await admin.goto("/settings/general");
await admin.waitForShell();
await admin.waitForLoading();
// Edit the tagline field
const taglineInput = page.getByLabel("Tagline");
await taglineInput.fill("My edited tagline");
// Simulate window blur + focus (triggers React Query refetches for stale queries)
await page.evaluate(() => {
window.dispatchEvent(new Event("blur"));
window.dispatchEvent(new Event("focus"));
});
// Wait for any potential refetch to complete
await page.waitForTimeout(1000);
// The edit should persist (staleTime: Infinity prevents refetch from overwriting)
await expect(taglineInput).toHaveValue("My edited tagline");
});
test("section editor edits survive window blur/focus", async ({ admin, page }) => {
await admin.goto("/sections/hero");
await admin.waitForShell();
await admin.waitForLoading();
// Edit the title field
const titleInput = page.getByLabel("Title");
const originalTitle = await titleInput.inputValue();
const editedTitle = `Edited ${Date.now()}`;
await titleInput.fill(editedTitle);
// Simulate window blur + focus (triggers React Query refetches without staleTime)
await page.evaluate(() => {
window.dispatchEvent(new Event("blur"));
window.dispatchEvent(new Event("focus"));
});
// Small wait for any potential refetch to complete
await page.waitForTimeout(1000);
// Edits should still be there (staleTime: Infinity prevents overwrite)
await expect(titleInput).toHaveValue(editedTitle);
// Save button should show "Save" (dirty), not "Saved" (clean)
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
// Restore original value to avoid side effects on other tests
await titleInput.fill(originalTitle);
});
test("section editor save failure keeps form dirty", async ({ admin, page }) => {
await admin.goto("/sections/hero");
await admin.waitForShell();
await admin.waitForLoading();
// Edit the title field so the form is dirty
const titleInput = page.getByLabel("Title");
const originalTitle = await titleInput.inputValue();
await titleInput.fill(`Error test ${Date.now()}`);
// Intercept the section update API call and force a failure
await page.route("**/api/sections/hero", (route) => {
if (route.request().method() === "PUT" || route.request().method() === "PATCH") {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: { code: "SERVER_ERROR", message: "Simulated failure" } }),
});
}
return route.continue();
});
// Click save
const saveButton = page.getByRole("button", { name: "Save" });
await saveButton.click();
// Wait for the error to be processed
await page.waitForTimeout(1000);
// The Save button should still be enabled (form is still dirty)
// It should NOT show "Saved" — the mutation failed
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
// Remove the route intercept and restore title
await page.unroute("**/api/sections/hero");
await titleInput.fill(originalTitle);
});
test("taxonomy checkboxes clear when all terms are removed", async ({
admin,
page,
serverInfo,
}) => {
// Navigate to a published post that we can assign terms to
const postId = serverInfo.contentIds["posts"]?.[0];
if (!postId) {
test.skip();
return;
}
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();
// Wait for the taxonomy sidebar to load
const taxonomyHeading = page.locator("h3", { hasText: "Taxonomies" });
await expect(taxonomyHeading).toBeVisible({ timeout: 10000 });
// Find the category checkboxes
const newsCheckbox = page.getByRole("checkbox", { name: "News" });
const tutorialsCheckbox = page.getByRole("checkbox", { name: "Tutorials" });
// Check two categories
await newsCheckbox.check();
await page.waitForTimeout(500); // Wait for auto-save
await tutorialsCheckbox.check();
await page.waitForTimeout(500); // Wait for auto-save
// Verify both are checked
await expect(newsCheckbox).toBeChecked();
await expect(tutorialsCheckbox).toBeChecked();
// Now uncheck both — this is the bug scenario from PR #133
await newsCheckbox.uncheck();
await page.waitForTimeout(500);
await tutorialsCheckbox.uncheck();
await page.waitForTimeout(500);
// All checkboxes should be unchecked (the old bug would leave stale checks)
await expect(newsCheckbox).not.toBeChecked();
await expect(tutorialsCheckbox).not.toBeChecked();
await expect(page.getByRole("checkbox", { name: "Opinion" })).not.toBeChecked();
// Reload to verify server state matches
await page.reload();
await admin.waitForShell();
await admin.waitForLoading();
// After reload, all should still be unchecked
await expect(taxonomyHeading).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("checkbox", { name: "News" })).not.toBeChecked();
await expect(page.getByRole("checkbox", { name: "Tutorials" })).not.toBeChecked();
await expect(page.getByRole("checkbox", { name: "Opinion" })).not.toBeChecked();
});
});

307
e2e/tests/i18n.spec.ts Normal file
View File

@@ -0,0 +1,307 @@
/**
* i18n E2E Tests
*
* Tests the internationalization features in the admin UI:
* - Locale column in content list
* - Locale filter in content list
* - Translations sidebar in content editor
* - Creating translations via the admin UI
* - Navigating between translations
* - Slug correctness (no locale suffix accumulation)
*
* The e2e fixture has i18n configured with locales: en, fr, es
* and defaultLocale: en.
*
* Seed data:
* - posts: "First Post" (en, published), "Second Post" (en, published), "Draft Post" (en, draft)
* - pages: "About" (en, published), "Contact" (en, draft)
*/
import { test, expect } from "../fixtures";
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+$/;
test.describe("i18n", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Content List", () => {
test("shows locale column when i18n is configured", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// The table should have a "Locale" column header
const localeHeader = admin.page.locator("th", { hasText: "Locale" });
await expect(localeHeader).toBeVisible();
});
test("displays locale badges for each content item", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// All seeded posts are English — should see EN badges
const locales = await admin.getLocaleColumnValues();
expect(locales.length).toBeGreaterThan(0);
// All seeded content is "en"
for (const locale of locales) {
expect(locale.trim().toLowerCase()).toBe("en");
}
});
test("has a locale filter switcher", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Should have a select element for locale filtering
const select = admin.page.locator("select").first();
await expect(select).toBeVisible();
// Should show available locale options
const options = select.locator("option");
const optionTexts = await options.allTextContents();
// Expect EN, FR, ES options (may also have "All locales")
expect(optionTexts.some((t) => t.includes("EN"))).toBe(true);
expect(optionTexts.some((t) => t.includes("FR"))).toBe(true);
expect(optionTexts.some((t) => t.includes("ES"))).toBe(true);
});
});
test.describe("Content Editor", () => {
test("shows translations sidebar for existing content", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
// Click on any post to edit (use first link in table body)
await admin.page.locator("table tbody tr a").first().click();
await admin.waitForLoading();
// Should see the Translations sidebar heading
const translationsHeading = admin.page.locator("h3", {
hasText: "Translations",
});
await expect(translationsHeading).toBeVisible();
});
test("shows all configured locales in translations sidebar", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
await admin.page.locator("table tbody tr a").first().click();
await admin.waitForLoading();
// Should show en, fr, es in the sidebar
const locales = await admin.getTranslationSidebarLocales();
const normalized = locales.map((l) => l.trim().toLowerCase());
expect(normalized).toContain("en");
expect(normalized).toContain("fr");
expect(normalized).toContain("es");
});
test("marks current locale in translations sidebar", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
await admin.page.locator("table tbody tr a").first().click();
await admin.waitForLoading();
// The "current" marker should appear next to EN
const currentMarker = admin.page.locator("span.text-kumo-brand", {
hasText: "current",
});
await expect(currentMarker).toBeVisible();
});
test("shows Translate buttons for missing locales", async ({ admin }) => {
await admin.goToContent("posts");
await admin.waitForLoading();
await admin.page.locator("table tbody tr a").first().click();
await admin.waitForLoading();
// FR and ES should have "Translate" buttons since no translations exist yet
expect(await admin.hasTranslateButton("fr")).toBe(true);
expect(await admin.hasTranslateButton("es")).toBe(true);
});
test("does not show translations sidebar for new content", async ({ admin }) => {
await admin.goToNewContent("posts");
await admin.waitForLoading();
// The translations sidebar should NOT be visible for unsaved content
const translationsHeading = admin.page.locator("h3", {
hasText: "Translations",
});
await expect(translationsHeading).not.toBeVisible();
});
});
test.describe("Translation Flow", () => {
test("creates a translation and navigates to it", async ({ admin }) => {
// Create a fresh post so we have a clean translation group
await admin.goToNewContent("posts");
await admin.waitForLoading();
const postTitle = `i18n Test Post ${Date.now()}`;
await admin.fillField("title", postTitle);
await admin.clickSave();
// Wait for redirect to edit page
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
await admin.waitForLoading();
// Capture the original post URL
const originalUrl = admin.page.url();
// Should see Translate buttons for FR and ES
expect(await admin.hasTranslateButton("fr")).toBe(true);
// Click "Translate" for FR — wait for URL to change (SPA navigation)
await admin.clickTranslate("fr");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
// The title should be pre-filled from the original
await expect(admin.page.locator("#field-title")).toHaveValue(postTitle);
// The slug should be the same as the original (no locale suffix)
const slug = await admin.page.getByLabel("Slug").inputValue();
expect(slug).not.toContain("-fr");
expect(slug).not.toContain("-en");
});
test("shows Edit link for existing translations", async ({ admin }) => {
// Create a post and its FR translation
await admin.goToNewContent("posts");
await admin.waitForLoading();
const postTitle = `Translation Edit Test ${Date.now()}`;
await admin.fillField("title", postTitle);
await admin.clickSave();
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
await admin.waitForLoading();
const originalUrl = admin.page.url();
// Create FR translation and wait for navigation
await admin.clickTranslate("fr");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
// Now on the FR translation — EN should show "Edit" link, not "Translate"
expect(await admin.hasEditTranslationLink("en")).toBe(true);
// ES should still show "Translate"
expect(await admin.hasTranslateButton("es")).toBe(true);
});
test("can navigate between translations via Edit links", async ({ admin }) => {
// Create a post and FR translation
await admin.goToNewContent("posts");
await admin.waitForLoading();
const postTitle = `Navigation Test ${Date.now()}`;
await admin.fillField("title", postTitle);
await admin.clickSave();
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
await admin.waitForLoading();
const originalUrl = admin.page.url();
// Create FR translation and wait for navigation
await admin.clickTranslate("fr");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
const frUrl = admin.page.url();
// Navigate back to EN via Edit link
await admin.clickEditTranslation("en");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== frUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
// Should be back on the original post
await expect(admin.page).toHaveURL(originalUrl);
await expect(admin.page.locator("#field-title")).toHaveValue(postTitle);
});
test("creating multiple translations does not accumulate locale suffixes in slugs", async ({
admin,
}) => {
// This is the regression test for the slug accumulation bug:
// old code: slug = rawItem.slug + "-" + locale
// Each translate would append more suffixes: post-fr-en-fr-en...
await admin.goToNewContent("posts");
await admin.waitForLoading();
const postTitle = `Slug Accumulation Test ${Date.now()}`;
await admin.fillField("title", postTitle);
await admin.clickSave();
await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, {
timeout: 10000,
});
await admin.waitForLoading();
const originalUrl = admin.page.url();
const originalSlug = await admin.page.getByLabel("Slug").inputValue();
expect(originalSlug).toBeTruthy();
// Create FR translation and wait for navigation
await admin.clickTranslate("fr");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
// FR slug should be the same as original (UNIQUE(slug, locale) allows this)
const frSlug = await admin.page.getByLabel("Slug").inputValue();
expect(frSlug).toBe(originalSlug);
const frUrl = admin.page.url();
// Navigate back to EN
await admin.clickEditTranslation("en");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== frUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
const enUrl = admin.page.url();
// Create ES translation from EN
await admin.clickTranslate("es");
await admin.page.waitForURL(
(url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== enUrl,
{ timeout: 15000 },
);
await admin.waitForLoading();
// ES slug should also be the same — no accumulation
const esSlug = await admin.page.getByLabel("Slug").inputValue();
expect(esSlug).toBe(originalSlug);
});
});
});

View File

@@ -0,0 +1,117 @@
/**
* Keyboard Shortcuts & Panel Dismiss E2E Tests
*
* Tests that keyboard shortcuts (Escape to close, Cmd+S to save) work
* correctly in slide-out panels, and that the Shell sidebar auto-closes
* on viewport resize.
*
* These verify the useStableCallback pattern — event listeners must
* remain functional across re-renders without churn.
*/
import { test, expect } from "../fixtures";
test.describe("Keyboard Shortcuts", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Media Detail Panel", () => {
test("Escape closes the media detail panel", async ({ admin, page }) => {
await admin.goToMedia();
await admin.waitForLoading();
// Seed data includes uploaded media — click the first grid item (a button)
const mediaItem = page.locator(".grid.gap-4 button").first();
await expect(mediaItem).toBeVisible({ timeout: 10000 });
await mediaItem.click();
// Panel should be visible
const panel = page.locator("text=Media Details");
await expect(panel).toBeVisible({ timeout: 5000 });
// Press Escape
await page.keyboard.press("Escape");
// Panel should be closed
await expect(panel).not.toBeVisible({ timeout: 3000 });
});
test("Cmd+S saves media detail changes", async ({ admin, page }) => {
await admin.goToMedia();
await admin.waitForLoading();
// Click the first media item
const mediaItem = page.locator(".grid.gap-4 button").first();
await expect(mediaItem).toBeVisible({ timeout: 10000 });
await mediaItem.click();
await expect(page.locator("text=Media Details")).toBeVisible({ timeout: 5000 });
// Change alt text (only visible for images)
const altInput = page.getByLabel("Alt Text");
if (await altInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await altInput.fill(`E2E Alt ${Date.now()}`);
// Listen for the update API call
const saveResponse = page.waitForResponse(
(res) =>
res.url().includes("/api/media/") &&
res.request().method() === "PUT" &&
res.status() === 200,
{ timeout: 10000 },
);
// Press Cmd+S (Control on Linux/CI)
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+s`);
// Should trigger the save
await saveResponse;
}
});
});
test.describe("User Detail Panel", () => {
test("Escape closes the user detail panel", async ({ admin, page }) => {
await admin.goto("/users");
await admin.waitForShell();
await admin.waitForLoading();
// Click a user row to open the detail panel
const userRow = page.locator("table tbody tr").first();
await expect(userRow).toBeVisible({ timeout: 10000 });
await userRow.click();
// Panel should appear
const panel = page.locator("text=User Details");
await expect(panel).toBeVisible({ timeout: 5000 });
// Press Escape
await page.keyboard.press("Escape");
// Panel should be closed
await expect(panel).not.toBeVisible({ timeout: 3000 });
});
});
test.describe("Shell Sidebar", () => {
test("sidebar becomes mobile sheet when viewport shrinks below md breakpoint", async ({
admin,
page,
}) => {
await admin.goToDashboard();
// Start at desktop width — sidebar should be visible as aside
await page.setViewportSize({ width: 1280, height: 720 });
const sidebar = page.locator('aside[aria-label="Admin navigation"]');
await expect(sidebar).toBeVisible();
// Shrink below kumo's mobile breakpoint (768px) — sidebar becomes a dialog sheet
await page.setViewportSize({ width: 600, height: 720 });
// The aside element should no longer be in the viewport
await expect(sidebar).not.toBeInViewport({ timeout: 3000 });
});
});
});

View File

@@ -0,0 +1,115 @@
/**
* Plugin Marketplace E2E Tests
*
* Tests the plugin marketplace admin pages:
* - Browse page at /plugins/marketplace
* - Detail page at /plugins/marketplace/{pluginId}
*
* These tests run against a mock marketplace server (port 4445) that serves
* canned plugin data. The proxy endpoints in the EmDash admin forward
* requests to the mock, so we're testing the full UI flow.
*/
import { test, expect } from "../fixtures";
// URL patterns (module scope for e18e/prefer-static-regex)
const PLUGIN_DETAIL_URL_PATTERN = /\/plugins\/marketplace\/seo-toolkit/;
const MARKETPLACE_BROWSE_URL_PATTERN = /\/plugins\/marketplace\/?$/;
test.describe("Plugin Marketplace", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Browse page", () => {
test("renders marketplace page with plugin cards", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
// Wait for at least one plugin card to appear (the mock serves SEO Toolkit)
await expect(page.getByText("SEO Toolkit")).toBeVisible({ timeout: 15000 });
});
test("plugin card shows name, author, version", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
// Wait for cards to load
await expect(page.getByText("SEO Toolkit")).toBeVisible({ timeout: 15000 });
// The card is a link element containing plugin info
const seoCard = page.locator("a", { hasText: "SEO Toolkit" }).first();
// Author
await expect(seoCard.getByText("Labs")).toBeVisible();
// Version
await expect(seoCard.getByText("v2.1.0")).toBeVisible();
});
test("search filters plugins by name", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.getByText("SEO Toolkit")).toBeVisible({ timeout: 15000 });
// Type in the search box
const searchInput = page.getByPlaceholder("Search plugins...");
await searchInput.fill("nonexistent-plugin-xyz");
// Wait for the debounced search to complete
await page.waitForTimeout(1000);
// No plugins should match
await expect(page.getByText("SEO Toolkit")).not.toBeVisible({ timeout: 5000 });
});
});
test.describe("Plugin detail page", () => {
test("navigates to detail page on card click", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
// Wait for cards
const seoCard = page.locator("a", { hasText: "SEO Toolkit" }).first();
await expect(seoCard).toBeVisible({ timeout: 15000 });
// Click the card
await seoCard.click();
// URL should include the plugin ID
await expect(page).toHaveURL(PLUGIN_DETAIL_URL_PATTERN, { timeout: 10000 });
});
test("detail page shows plugin info", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace/seo-toolkit");
await admin.waitForShell();
await admin.waitForLoading();
// Plugin name in heading (use first() since sidebar may also have an h1)
await expect(page.locator("h1").first()).toContainText("SEO Toolkit", { timeout: 15000 });
// Author
await expect(page.getByText("EmDash Labs").first()).toBeVisible();
});
test("back link navigates to browse page", async ({ admin, page }) => {
await admin.goto("/plugins/marketplace/seo-toolkit");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.locator("h1").first()).toContainText("SEO Toolkit", { timeout: 15000 });
// Click the back link (look for any link going back to marketplace)
const backLink = page.locator("a", { hasText: "Marketplace" }).first();
await backLink.click();
// Should navigate back to browse page
await expect(page).toHaveURL(MARKETPLACE_BROWSE_URL_PATTERN, { timeout: 10000 });
});
});
});

View File

@@ -0,0 +1,137 @@
/**
* Media Library E2E Tests
*
* Tests uploading, viewing, and deleting media files.
* Runs against an isolated fixture — starts with no media.
*/
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { test, expect } from "../fixtures";
// Create a test image for uploads
const TEST_ASSETS_DIR = join(process.cwd(), "e2e/fixtures/assets");
// Regex patterns
const MEDIA_API_RESPONSE_PATTERN = /\/api\/media/;
const UPLOAD_BUTTON_REGEX = /Upload/;
function ensureTestAssets(): string {
if (!existsSync(TEST_ASSETS_DIR)) {
mkdirSync(TEST_ASSETS_DIR, { recursive: true });
}
// Create a simple test PNG (1x1 red pixel)
const testImagePath = join(TEST_ASSETS_DIR, "test-image.png");
if (!existsSync(testImagePath)) {
// Minimal valid PNG file (1x1 red pixel)
const pngData = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe, 0xd4, 0xef, 0x00, 0x00,
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
writeFileSync(testImagePath, pngData);
}
return testImagePath;
}
test.describe("Media Library", () => {
test.beforeAll(() => {
ensureTestAssets();
});
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Media List", () => {
test("displays media library page", async ({ admin }) => {
await admin.goToMedia();
await admin.waitForLoading();
// Should show the media library heading
await admin.expectPageTitle("Media Library");
// Should have upload button
await expect(
admin.page.getByRole("button", { name: UPLOAD_BUTTON_REGEX }).first(),
).toBeVisible();
});
test("shows grid view by default", async ({ admin }) => {
await admin.goToMedia();
await admin.waitForLoading();
// Grid view button should be active
const gridButton = admin.page.locator('button[aria-label="Grid view"]');
await expect(gridButton).toBeVisible();
});
test("shows view toggle buttons", async ({ admin }) => {
await admin.goToMedia();
await admin.waitForLoading();
// View toggle buttons should be visible (grid and list icons)
const buttons = admin.page.locator("button").filter({ has: admin.page.locator("svg") });
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("Upload Media", () => {
test("uploads a new image file", async ({ admin, page }) => {
await admin.goToMedia();
await admin.waitForLoading();
// Upload file
const testImagePath = ensureTestAssets();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testImagePath);
// Wait for upload
await page.waitForResponse(
(res) => MEDIA_API_RESPONSE_PATTERN.test(res.url()) && res.status() === 200,
{ timeout: 10000 },
);
// Wait for the uploaded image to appear in the media grid
const mediaGrid = page.locator(".grid.gap-4");
await expect(mediaGrid.locator("img").first()).toBeVisible({ timeout: 5000 });
// Should have at least one image in the grid now
const images = mediaGrid.locator("img");
const count = await images.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("List View", () => {
test("shows file details in list view", async ({ admin, page }) => {
// Upload a file first so there's something to show
await admin.goToMedia();
await admin.waitForLoading();
const testImagePath = ensureTestAssets();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(testImagePath);
await page.waitForResponse(
(res) => MEDIA_API_RESPONSE_PATTERN.test(res.url()) && res.status() === 200,
{ timeout: 10000 },
);
await page.reload();
await admin.waitForLoading();
// Switch to list view
await page.click('button[aria-label="List view"]');
// Should show table with columns
await expect(page.locator("th:has-text('Filename')")).toBeVisible();
await expect(page.locator("th:has-text('Type')")).toBeVisible();
await expect(page.locator("th:has-text('Size')")).toBeVisible();
});
});
});

204
e2e/tests/menus.spec.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* Menus E2E Tests
*
* Tests creating, editing, and managing navigation menus.
* Runs against an isolated fixture — starts with no menus.
*/
import { test, expect } from "../fixtures";
test.describe("Menus", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Menu List", () => {
test("displays menus page", async ({ admin }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Should show menus heading
await admin.expectPageTitle("Menus");
// Should have Create Menu button
await expect(admin.page.getByRole("button", { name: "Create Menu" }).first()).toBeVisible();
});
test("shows empty state when no menus", async ({ admin }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Should show empty state message
await expect(admin.page.locator("text=No menus yet")).toBeVisible();
});
});
test.describe("Create Menu", () => {
test("opens create menu dialog", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Click create menu button
await page.getByRole("button", { name: "Create Menu" }).first().click();
// Dialog should appear
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test("creates a new menu", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
const menuName = `test-menu-${Date.now()}`;
const menuLabel = "E2E Test Menu";
// Create menu
await admin.createMenu(menuName, menuLabel);
// Should redirect to menu editor
await expect(page).toHaveURL(new RegExp(`/menus/${menuName}$`));
// Should show the menu label
await expect(page.locator("h1")).toContainText(menuLabel);
});
test("cancels menu creation", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Open dialog
await page.getByRole("button", { name: "Create Menu" }).first().click();
// Click cancel
await page.click('button:has-text("Cancel")');
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
});
test.describe("Edit Menu", () => {
test("shows empty items state for new menu", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create a new empty menu
const menuName = `empty-menu-${Date.now()}`;
await admin.createMenu(menuName, "Empty Menu");
// Should show empty state
await expect(page.locator("text=No menu items yet")).toBeVisible();
});
test("adds custom link to menu", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create a new menu
const menuName = `links-menu-${Date.now()}`;
await admin.createMenu(menuName, "Links Menu");
// Add a custom link
await admin.addMenuLink("Home", "https://example.com");
// Should show the new item in the menu editor
const main = page.locator("main");
await expect(main.locator("text=Home")).toBeVisible();
await expect(main.locator("text=https://example.com")).toBeVisible();
});
test("adds multiple menu items", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create a new menu
const menuName = `multi-menu-${Date.now()}`;
await admin.createMenu(menuName, "Multi Menu");
// Add multiple links
await admin.addMenuLink("Home", "https://example.com");
await admin.addMenuLink("About", "https://example.com/about");
await admin.addMenuLink("Contact", "https://example.com/contact");
// All URLs should be visible (unique to the menu items)
await expect(page.locator("text=https://example.com").first()).toBeVisible();
await expect(page.locator("text=https://example.com/about")).toBeVisible();
await expect(page.locator("text=https://example.com/contact")).toBeVisible();
});
test("deletes menu item", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create menu with item
const menuName = `delete-item-${Date.now()}`;
await admin.createMenu(menuName, "Delete Item Menu");
await admin.addMenuLink("To Delete", "https://example.com");
// Verify item exists
await expect(page.locator("text=To Delete")).toBeVisible();
// Click the trash icon button (last button in the menu item row)
const itemRow = page.locator(".border.rounded-lg").filter({ hasText: "To Delete" });
await itemRow.locator("button").last().click();
// Item should be removed
await admin.waitForLoading();
await expect(page.locator("text=To Delete")).not.toBeVisible();
});
});
test.describe("Delete Menu", () => {
test("deletes a menu from list", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create a menu to delete
const menuName = `to-delete-${Date.now()}`;
const menuLabel = "To Delete Menu";
await admin.createMenu(menuName, menuLabel);
// Go back to list
await admin.goToMenus();
await admin.waitForLoading();
// Menu should be in list
await expect(page.locator(`text=${menuLabel}`).first()).toBeVisible();
// Click trash icon on the menu card (last button in the card row)
const menuCard = page.locator(".rounded-lg").filter({ hasText: menuLabel }).first();
await menuCard.getByRole("button").last().click();
// Confirm deletion in alert dialog
await page.getByRole("button", { name: "Delete" }).click();
// Menu should be removed
await admin.waitForLoading();
await expect(page.locator(`text=${menuLabel}`)).not.toBeVisible();
});
test("cancel delete keeps menu", async ({ admin, page }) => {
await admin.goToMenus();
await admin.waitForLoading();
// Create a menu
const menuName = `keep-menu-${Date.now()}`;
const menuLabel = "Keep This Menu";
await admin.createMenu(menuName, menuLabel);
// Go back to list
await admin.goToMenus();
await admin.waitForLoading();
// Click trash icon
const menuCard = page.locator(".rounded-lg").filter({ hasText: menuLabel }).first();
await menuCard.getByRole("button").last().click();
// Cancel deletion
await page.getByRole("button", { name: "Cancel" }).click();
// Menu should still be there
await expect(page.locator(`text=${menuLabel}`).first()).toBeVisible();
});
});
});

228
e2e/tests/plugins.spec.ts Normal file
View File

@@ -0,0 +1,228 @@
/**
* Plugins Manager E2E Tests
*
* Tests the plugins manager admin page at /plugins-manager.
* Verifies that the page renders, displays plugin info, and
* supports enable/disable toggling.
*
* This test exists because the plugins page previously crashed due to
* incorrect API response envelope unwrapping (fetchPlugins returned
* { items: [...] } instead of [...]) -- a bug that component tests
* never caught because they mock fetchPlugins at the module level.
*
* The e2e fixture configures a color plugin (id: "color", version: "0.0.1").
*/
import { test, expect } from "../fixtures";
test.describe("Plugins Manager", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Page rendering", () => {
test("displays the plugins page with at least one plugin", async ({ admin, page }) => {
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
// Page title
await admin.expectPageTitle("Plugins");
// Should show the plugin count
await expect(page.locator("text=/\\d+ plugin/")).toBeVisible({ timeout: 10000 });
// Should have at least one plugin card (the color plugin from the fixture)
const pluginCards = page.locator(".rounded-lg.border.bg-kumo-base");
await expect(pluginCards.first()).toBeVisible({ timeout: 10000 });
});
test("does not show a crash or error state", async ({ admin, page }) => {
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
// The bug that prompted this test caused the page to show an error.
// Verify no error state is visible.
await expect(page.locator("text=Failed to load plugins")).not.toBeVisible();
});
});
test.describe("Plugin card info", () => {
test("shows plugin name, version, and toggle", async ({ admin, page }) => {
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
// Find the color plugin card
const colorCard = page
.locator(".rounded-lg.border.bg-kumo-base", { hasText: "color" })
.first();
await expect(colorCard).toBeVisible({ timeout: 10000 });
// Plugin name
await expect(colorCard.locator("h3")).toContainText("color");
// Version badge
await expect(colorCard.locator("text=v0.0.1")).toBeVisible();
// Enable/disable switch
const toggle = colorCard.locator("button[role='switch']");
await expect(toggle).toBeVisible();
});
});
test.describe("Enable / disable toggle", () => {
test.skip("can disable a plugin and see the Disabled badge", async ({ admin, page }) => {
// TODO: Enable/disable only works for marketplace plugins, not config plugins.
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
const colorCard = page
.locator(".rounded-lg.border.bg-kumo-base", { hasText: "color" })
.first();
await expect(colorCard).toBeVisible({ timeout: 10000 });
const toggle = colorCard.locator("button[role='switch']");
await expect(toggle).toBeVisible({ timeout: 5000 });
// If the plugin is currently disabled, enable it first
const isChecked = await toggle.getAttribute("aria-checked");
if (isChecked !== "true") {
await toggle.click();
await page.waitForTimeout(2000);
await admin.waitForLoading();
}
// Now disable the plugin
await toggle.click();
await page.waitForTimeout(2000);
await admin.waitForLoading();
// The "Disabled" badge should appear on the card
await expect(colorCard.getByText("Disabled")).toBeVisible({ timeout: 10000 });
// The toggle should now be unchecked
await expect(toggle).toHaveAttribute("aria-checked", "false");
});
test.skip("can re-enable a disabled plugin", async ({ admin, page }) => {
// TODO: Enable/disable only works for marketplace plugins, not config plugins.
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
const colorCard = page
.locator(".rounded-lg.border.bg-kumo-base", { hasText: "color" })
.first();
await expect(colorCard).toBeVisible({ timeout: 10000 });
const toggle = colorCard.locator("button[role='switch']");
await expect(toggle).toBeVisible({ timeout: 5000 });
// Ensure the plugin is disabled first
const isChecked = await toggle.getAttribute("aria-checked");
if (isChecked === "true") {
await toggle.click();
await page.waitForTimeout(2000);
await admin.waitForLoading();
}
// Verify it shows "Disabled"
await expect(colorCard.getByText("Disabled")).toBeVisible({ timeout: 10000 });
// Re-enable it
await toggle.click();
await page.waitForTimeout(2000);
await admin.waitForLoading();
// The "Disabled" badge should be gone
await expect(colorCard.getByText("Disabled")).not.toBeVisible({ timeout: 10000 });
// Toggle should be checked again
await expect(toggle).toHaveAttribute("aria-checked", "true");
});
});
test.describe("Expand details", () => {
test("expand button reveals details section", async ({ admin, page }) => {
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
const colorCard = page
.locator(".rounded-lg.border.bg-kumo-base", { hasText: "color" })
.first();
await expect(colorCard).toBeVisible({ timeout: 10000 });
// Find and click the expand button
const expandBtn = colorCard.locator("button[aria-label='Expand details']");
await expect(expandBtn).toBeVisible();
await expandBtn.click();
// The details section should now be visible (it has a border-t class)
const detailsSection = colorCard.locator(".border-t.px-4");
await expect(detailsSection).toBeVisible({ timeout: 5000 });
// Collapse button should now be present
await expect(colorCard.locator("button[aria-label='Collapse details']")).toBeVisible();
});
test("collapse button hides the details section", async ({ admin, page }) => {
await admin.goto("/plugins-manager");
await admin.waitForShell();
await admin.waitForLoading();
const colorCard = page
.locator(".rounded-lg.border.bg-kumo-base", { hasText: "color" })
.first();
await expect(colorCard).toBeVisible({ timeout: 10000 });
// Expand first
await colorCard.locator("button[aria-label='Expand details']").click();
const detailsSection = colorCard.locator(".border-t.px-4");
await expect(detailsSection).toBeVisible({ timeout: 5000 });
// Now collapse
await colorCard.locator("button[aria-label='Collapse details']").click();
// Details should be hidden
await expect(detailsSection).not.toBeVisible({ timeout: 5000 });
// Expand button should be back
await expect(colorCard.locator("button[aria-label='Expand details']")).toBeVisible();
});
});
test.describe("API integration", () => {
test("plugins API returns the correct envelope shape", async ({ page, serverInfo }) => {
// Directly verify the API response shape that caused the original bug.
// fetchPlugins expected { data: { items: [...] } } but was getting
// the items at a different nesting level.
const res = await page.request.get("/_emdash/api/admin/plugins", {
headers: {
"X-EmDash-Request": "1",
Authorization: `Bearer ${serverInfo.token}`,
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
// The response should have { data: { items: [...] } }
expect(body.data).toBeDefined();
expect(body.data.items).toBeDefined();
expect(Array.isArray(body.data.items)).toBe(true);
expect(body.data.items.length).toBeGreaterThan(0);
// Each plugin should have the expected shape
const plugin = body.data.items[0];
expect(plugin.id).toBeDefined();
expect(plugin.name).toBeDefined();
expect(plugin.version).toBeDefined();
expect(typeof plugin.enabled).toBe("boolean");
expect(Array.isArray(plugin.capabilities)).toBe(true);
});
});
});

143
e2e/tests/redirects.spec.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Redirects E2E Tests
*
* Tests creating, editing, and deleting URL redirects,
* plus the 404 tracking tab.
*/
import { test, expect } from "../fixtures";
test.describe("Redirects", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Empty state", () => {
test("displays redirects page with empty state", async ({ admin, page }) => {
await admin.goto("/redirects");
await admin.waitForShell();
await admin.waitForLoading();
// Should show the page heading
await admin.expectPageTitle("Redirects");
// Should have the "New Redirect" button
await expect(page.getByRole("button", { name: "New Redirect" })).toBeVisible();
// Should show empty state text
await expect(page.locator("text=No redirects yet")).toBeVisible();
});
});
test.describe("CRUD", () => {
test("creates a redirect", async ({ admin, page }) => {
await admin.goto("/redirects");
await admin.waitForShell();
await admin.waitForLoading();
// Open create dialog
await page.getByRole("button", { name: "New Redirect" }).click();
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Fill form
const dialog = page.locator('[role="dialog"]');
await dialog.locator('input[placeholder*="old-page"]').fill("/old-page");
await dialog.locator('input[placeholder*="new-page"]').fill("/new-page");
// Status code defaults to 301 -- leave it
// Submit
await dialog.getByRole("button", { name: "Create" }).click();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 });
// Redirect should appear in the list
await expect(page.locator("text=/old-page").first()).toBeVisible();
await expect(page.locator("text=/new-page").first()).toBeVisible();
});
test("edits a redirect", async ({ admin, page }) => {
await admin.goto("/redirects");
await admin.waitForShell();
await admin.waitForLoading();
// Create a redirect first
await page.getByRole("button", { name: "New Redirect" }).click();
const createDialog = page.locator('[role="dialog"]');
await createDialog.locator('input[placeholder*="old-page"]').fill("/edit-source");
await createDialog.locator('input[placeholder*="new-page"]').fill("/edit-dest-original");
await createDialog.getByRole("button", { name: "Create" }).click();
await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 });
// Wait for the redirect to appear
await expect(page.locator("text=/edit-source").first()).toBeVisible();
// Click the edit button on that row (use .first() to avoid ancestor div ambiguity)
await page.locator('button[title="Edit redirect"]').first().click();
// Edit dialog should open
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Change the destination
const editDialog = page.locator('[role="dialog"]');
const destInput = editDialog.locator('input[placeholder*="new-page"]');
await destInput.clear();
await destInput.fill("/edit-dest-updated");
// Save
await editDialog.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 });
// Verify the updated destination is shown
await expect(page.locator("text=/edit-dest-updated").first()).toBeVisible();
await expect(page.locator("text=/edit-dest-original")).not.toBeVisible();
});
test("deletes a redirect", async ({ admin, page }) => {
await admin.goto("/redirects");
await admin.waitForShell();
await admin.waitForLoading();
// Create a redirect to delete
await page.getByRole("button", { name: "New Redirect" }).click();
const dialog = page.locator('[role="dialog"]');
await dialog.locator('input[placeholder*="old-page"]').fill("/to-delete");
await dialog.locator('input[placeholder*="new-page"]').fill("/deleted-dest");
await dialog.getByRole("button", { name: "Create" }).click();
await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 });
// Wait for it to appear
await expect(page.locator("text=/to-delete").first()).toBeVisible();
// Click the delete button on that row (use .first() to avoid ancestor div ambiguity)
await page.locator('button[title="Delete redirect"]').first().click();
// Confirm deletion in the ConfirmDialog
await expect(page.locator('[role="dialog"]')).toBeVisible();
await page.getByRole("button", { name: "Delete" }).click();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 });
// Redirect should be gone
await expect(page.locator("text=/to-delete")).not.toBeVisible();
});
});
test.describe("404 Tracking", () => {
test("renders the 404 errors tab", async ({ admin, page }) => {
await admin.goto("/redirects");
await admin.waitForShell();
await admin.waitForLoading();
// Click the "404 Errors" tab
await page.locator("button", { hasText: "404 Errors" }).click();
// Should show the empty state for 404s
await expect(page.locator("text=No 404 errors recorded yet")).toBeVisible({
timeout: 10000,
});
});
});
});

364
e2e/tests/revisions.spec.ts Normal file
View File

@@ -0,0 +1,364 @@
/**
* Revisions E2E Tests
*
* Tests revision history in the content editor.
* Creates a dedicated collection with revision support because the
* fixture seed's posts collection doesn't include `supports: ["revisions"]`.
*
* Covers:
* - Edit creates a new revision
* - Revision history panel shows revisions
* - Restoring a revision updates content
*/
import { test, expect } from "../fixtures";
// Regex patterns
const REVISIONS_API_PATTERN = /\/api\/content\/[^/]+\/[^/]+\/revisions/;
// API helper
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
test.describe("Revisions", () => {
let collectionSlug: string;
let postId: string;
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
// Create a collection with revision + draft support
collectionSlug = `rev_test_${Date.now()}`;
await fetch(`${baseUrl}/_emdash/api/schema/collections`, {
method: "POST",
headers,
body: JSON.stringify({
slug: collectionSlug,
label: "Revision Test",
labelSingular: "Revision Test",
supports: ["revisions", "drafts"],
}),
});
// Add a title field
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers,
body: JSON.stringify({ slug: "title", type: "string", label: "Title", required: true }),
});
// Add an excerpt field for multi-field revision diffs
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers,
body: JSON.stringify({ slug: "excerpt", type: "text", label: "Excerpt" }),
});
// Create and publish a post (publishing creates the first live revision)
const createRes = await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}`, {
method: "POST",
headers,
body: JSON.stringify({
data: { title: "Original Title", excerpt: "Original excerpt" },
slug: "revision-test-post",
}),
});
const createData: any = await createRes.json();
postId = createData.data?.item?.id ?? createData.data?.id;
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
});
test.afterEach(async () => {
// Clean up test data
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "DELETE",
headers,
}).catch(() => {});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
test("revision history panel is visible for collections with revision support", async ({
admin,
page,
}) => {
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
// The title field should have the original value
await expect(page.locator("#field-title")).toHaveValue("Original Title");
// The Revisions panel should be visible (collapsed by default)
const revisionsButton = page.locator("button", { hasText: "Revisions" });
await expect(revisionsButton).toBeVisible({ timeout: 10000 });
});
test("expanding revision history shows existing revisions", async ({ admin, page }) => {
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
// Click on the Revisions header to expand
const revisionsButton = page.locator("button", { hasText: "Revisions" });
await revisionsButton.click();
// Wait for revisions to load (API call)
await page
.waitForResponse((res) => REVISIONS_API_PATTERN.test(res.url()) && res.status() === 200, {
timeout: 10000,
})
.catch(() => {});
// Should show at least one revision entry
// The latest revision has a "Current" badge (a span.badge element)
const currentBadge = page.locator("span.rounded-full", { hasText: "Current" });
await expect(currentBadge.first()).toBeVisible({ timeout: 10000 });
});
test("editing and saving creates a new revision", async ({ admin, page }) => {
// Get initial revision count
const initialRes = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const initialData: any = await initialRes.json();
const initialCount = initialData.data?.total ?? 0;
// Navigate to edit
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
const titleInput = page.locator("#field-title");
await expect(titleInput).toHaveValue("Original Title");
// Wait for any initial autosave to settle
await page.waitForTimeout(3000);
// Edit the title -- autosave will fire after debounce
const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`;
const autosavePut = page.waitForResponse(
(res) => res.url().includes(contentUrl) && res.request().method() === "PUT",
{ timeout: 15000 },
);
await titleInput.fill("Updated Title for Revision");
const response = await autosavePut;
expect(response.status()).toBe(200);
// Wait for autosave indicator
await expect(page.locator("text=Saved")).toBeVisible({ timeout: 5000 });
// Now publish to create a new live revision
const publishButton = page.getByRole("button", { name: "Publish" });
if (await publishButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await publishButton.click();
await admin.waitForLoading();
}
// Check that revision count increased
const afterRes = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const afterData: any = await afterRes.json();
const afterCount = afterData.data?.total ?? 0;
expect(afterCount).toBeGreaterThan(initialCount);
});
test("can view a revision's data by clicking on it", async ({ admin, page }) => {
// Create a second revision by updating via API + publishing
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "PUT",
headers,
body: JSON.stringify({ data: { title: "Second Version", excerpt: "Updated excerpt" } }),
});
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
// Navigate to edit
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
// Expand revisions panel
const revisionsButton = page.locator("button", { hasText: "Revisions" });
await revisionsButton.click();
// Wait for revisions to load
await page
.waitForResponse((res) => REVISIONS_API_PATTERN.test(res.url()) && res.status() === 200, {
timeout: 10000,
})
.catch(() => {});
// Should see the "Current" badge on the latest revision
await expect(page.locator("span.rounded-full", { hasText: "Current" }).first()).toBeVisible({
timeout: 10000,
});
// There should be multiple revision items (rounded-md border entries)
const revisionItems = page.locator(".rounded-md.border.p-3");
const count = await revisionItems.count();
expect(count).toBeGreaterThanOrEqual(2);
// Click on the non-latest (older) revision to view its data
// The second item (index 1) is the older revision
const olderRevision = revisionItems.nth(1);
await olderRevision.locator("button.flex-1.text-left").click();
// A diff view or snapshot should appear
// Look for either "Content snapshot" or a diff with field changes
const snapshotOrDiff = page.locator("text=Content snapshot").or(page.locator("text=change"));
await expect(snapshotOrDiff.first()).toBeVisible({ timeout: 5000 });
});
test("restoring a revision updates the content", async ({ admin, page }) => {
const originalTitle = "Original Title";
// Create a second version via API
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "PUT",
headers,
body: JSON.stringify({ data: { title: "Changed Title", excerpt: "Changed excerpt" } }),
});
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
// Get revision list to find the older revision's ID
const revisionsRes = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const revisionsData: any = await revisionsRes.json();
const revisions = revisionsData.data?.items ?? [];
// Need at least 2 revisions to restore
expect(revisions.length).toBeGreaterThanOrEqual(2);
// The first item (index 0) is the latest, older ones follow
const olderRevision = revisions[1];
expect(olderRevision).toBeDefined();
// Navigate to the editor
await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
// Verify we have the latest title
await expect(page.locator("#field-title")).toHaveValue("Changed Title");
// Expand revisions
const revisionsButton = page.locator("button", { hasText: "Revisions" });
await revisionsButton.click();
// Wait for revisions to load
await page
.waitForResponse((res) => REVISIONS_API_PATTERN.test(res.url()) && res.status() === 200, {
timeout: 10000,
})
.catch(() => {});
// Wait for revision items to render
const revisionItems = page.locator(".rounded-md.border.p-3");
await expect(revisionItems.first()).toBeVisible({ timeout: 10000 });
// Find the restore button on the older revision (not the "Current" one)
// The restore button uses ArrowCounterClockwise icon and title="Restore this version"
const restoreButton = page.locator('button[title="Restore this version"]').first();
await expect(restoreButton).toBeVisible({ timeout: 5000 });
// Click restore -- this opens a ConfirmDialog
await restoreButton.click();
// ConfirmDialog should appear
const confirmDialog = page.getByRole("dialog", { name: "Restore Revision" });
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
// Confirm the restore
await confirmDialog.getByRole("button", { name: "Restore" }).click();
// Wait for the restore to complete
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
// Wait for the page to update with restored content
await admin.waitForLoading();
// Verify via API that the content was restored
const contentRes = await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
headers,
});
const contentData: any = await contentRes.json();
const currentTitle = contentData.data?.item?.data?.title ?? contentData.data?.item?.title;
// The title should be back to the original
expect(currentTitle).toBe(originalTitle);
});
test("restore creates a new revision in the history", async ({ admin: _admin }) => {
// Create a second version
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "PUT",
headers,
body: JSON.stringify({ data: { title: "Version 2", excerpt: "v2 excerpt" } }),
});
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/publish`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
// Get revision count before restore
const beforeRes = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const beforeData: any = await beforeRes.json();
const countBefore = beforeData.data?.total ?? 0;
const revisions = beforeData.data?.items ?? [];
expect(revisions.length).toBeGreaterThanOrEqual(2);
const olderRevisionId = revisions[1].id;
// Restore via API
const restoreRes = await fetch(
`${baseUrl}/_emdash/api/revisions/${olderRevisionId}/restore`,
{
method: "POST",
headers,
},
);
expect(restoreRes.status).toBe(200);
// Get revision count after restore -- should have increased
const afterRes = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/revisions`,
{ headers },
);
const afterData: any = await afterRes.json();
const countAfter = afterData.data?.total ?? 0;
expect(countAfter).toBeGreaterThan(countBefore);
});
});

441
e2e/tests/search.spec.ts Normal file
View File

@@ -0,0 +1,441 @@
/**
* Search E2E Tests
*
* Tests the search functionality via:
* 1. The AdminCommandPalette (Cmd+K) which provides UI-driven content search
* 2. Direct API calls to the search endpoints
*
* Search must be enabled per-collection before it returns content results.
* The fixture does not enable search by default, so tests enable it first.
*
* Seed data:
* - posts: "First Post" (published), "Second Post" (published),
* "Draft Post" (draft), "Post With Image" (published)
* - pages: "About" (published), "Contact" (draft)
*/
import { test, expect } from "../fixtures";
// Regex patterns (module scope per lint rules)
const MEDIA_URL_PATTERN = /\/media/;
const CONTENT_POSTS_URL_PATTERN = /\/content\/posts\//;
// Keyboard modifier for Cmd (Mac) / Ctrl (Linux/Windows)
const MOD_KEY = process.platform === "darwin" ? "Meta" : "Control";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Make an authenticated API request to the test server.
*/
async function apiRequest(
serverInfo: { baseUrl: string; token: string },
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {
Authorization: `Bearer ${serverInfo.token}`,
"X-EmDash-Request": "1",
Origin: serverInfo.baseUrl,
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
return fetch(`${serverInfo.baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/**
* Enable search for a collection, silently ignoring if already enabled.
*/
/**
* Mark a field as searchable via the schema API.
* The auto-seed creates collections without the seed.json's searchable flags,
* so we need to set them via API before enabling FTS.
*/
async function markFieldSearchable(
serverInfo: { baseUrl: string; token: string },
collection: string,
fieldSlug: string,
): Promise<void> {
await apiRequest(
serverInfo,
"PUT",
`/_emdash/api/schema/collections/${collection}/fields/${fieldSlug}`,
{ searchable: true },
);
}
async function enableSearch(
serverInfo: { baseUrl: string; token: string },
collection: string,
): Promise<void> {
// Ensure at least one field is searchable before enabling FTS
await markFieldSearchable(serverInfo, collection, "title");
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/enable", {
collection,
enabled: true,
});
// Accept both 200 (success) and 400/409 (already enabled or no searchable fields)
if (!res.ok && res.status !== 400 && res.status !== 409) {
const text = await res.text().catch(() => "");
throw new Error(`Failed to enable search for ${collection} (${res.status}): ${text}`);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Search", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Command Palette", () => {
test("opens with Cmd+K keyboard shortcut", async ({ admin, page }) => {
await admin.goToDashboard();
// Press Cmd+K to open the command palette
await page.keyboard.press(`${MOD_KEY}+k`);
// The command palette should be visible with a search input
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
});
test("closes with Escape", async ({ admin, page }) => {
await admin.goToDashboard();
// Open command palette
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Close with Escape
await page.keyboard.press("Escape");
await expect(input).not.toBeVisible({ timeout: 3000 });
});
test("shows navigation items by default", async ({ admin, page }) => {
await admin.goToDashboard();
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// The command palette dialog should show navigation items
const dialog = page.locator('[role="dialog"]');
await expect(dialog.getByText("Dashboard")).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText("Media")).toBeVisible();
});
test("filters navigation items when typing", async ({ admin, page }) => {
await admin.goToDashboard();
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Type a query that matches "Settings"
await input.fill("sett");
// Settings should still be visible, but Dashboard should be filtered out
await expect(page.getByText("Settings")).toBeVisible({ timeout: 5000 });
});
test("shows empty state for no matches", async ({ admin, page }) => {
await admin.goToDashboard();
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Type something that won't match any nav or content
await input.fill("zzzznonexistentxyzzy");
// Should show "No results found" eventually (after debounce + API response)
await expect(page.getByText("No results found")).toBeVisible({ timeout: 10000 });
});
test("navigates to a page when selecting a nav item", async ({ admin, page }) => {
await admin.goToDashboard();
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Type "media" to filter
await input.fill("media");
// Click on the Media Library result
const mediaItem = page.getByText("Media Library");
await expect(mediaItem).toBeVisible({ timeout: 5000 });
await mediaItem.click();
// Should navigate to the media page
await expect(page).toHaveURL(MEDIA_URL_PATTERN, { timeout: 10000 });
});
});
test.describe("Search API", () => {
test("search endpoint requires authentication", async ({ serverInfo }) => {
// Request without auth token
const res = await fetch(`${serverInfo.baseUrl}/_emdash/api/search?q=test`);
// Should be 401 or 403
expect([401, 403]).toContain(res.status);
});
test("search endpoint requires a query parameter", async ({ serverInfo }) => {
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search");
// Missing required `q` param should fail validation
expect(res.status).toBe(400);
});
test("search returns results after enabling search", async ({ serverInfo }) => {
// Enable search for posts
await enableSearch(serverInfo, "posts");
// Rebuild the index so seeded content is indexed
await apiRequest(serverInfo, "POST", "/_emdash/api/search/rebuild", {
collection: "posts",
});
// Search for "First" -- should match "First Post"
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search?q=First&limit=10");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toBeDefined();
expect(body.data.items).toBeInstanceOf(Array);
// Should find at least the "First Post"
const titles = body.data.items.map((item: any) => item.title);
expect(titles).toContain("First Post");
});
test("search filters by collection", async ({ serverInfo }) => {
// Ensure search is enabled for posts
await enableSearch(serverInfo, "posts");
// Search only in posts
const res = await apiRequest(
serverInfo,
"GET",
"/_emdash/api/search?q=Post&collections=posts&limit=20",
);
expect(res.status).toBe(200);
const body = await res.json();
const items = body.data.items;
// All results should be from the posts collection
for (const item of items) {
expect(item.collection).toBe("posts");
}
});
test("search returns empty for non-matching query", async ({ serverInfo }) => {
await enableSearch(serverInfo, "posts");
const res = await apiRequest(
serverInfo,
"GET",
"/_emdash/api/search?q=zzzznonexistentxyzzy&limit=10",
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.items).toHaveLength(0);
});
test("search respects limit parameter", async ({ serverInfo }) => {
await enableSearch(serverInfo, "posts");
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search?q=Post&limit=2");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.items.length).toBeLessThanOrEqual(2);
});
});
test.describe("Search Suggestions API", () => {
test.fixme("returns suggestions for partial queries", async ({ serverInfo }) => {
// TODO: getSuggestions fails in dev mode -- needs investigation
await enableSearch(serverInfo, "posts");
const res = await apiRequest(
serverInfo,
"GET",
"/_emdash/api/search/suggest?q=Fir&limit=5",
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toBeDefined();
expect(body.data.items).toBeInstanceOf(Array);
});
test("suggestions require a query parameter", async ({ serverInfo }) => {
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search/suggest");
expect(res.status).toBe(400);
});
});
test.describe("Search Stats API", () => {
test("returns search index statistics", async ({ serverInfo }) => {
await enableSearch(serverInfo, "posts");
const res = await apiRequest(serverInfo, "GET", "/_emdash/api/search/stats");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toBeDefined();
});
});
test.describe("Search Enable/Disable API", () => {
test("enables search for a collection", async ({ serverInfo }) => {
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/enable", {
collection: "pages",
enabled: true,
});
// May succeed (200) or fail if already enabled or no searchable fields
// Either way it should not be a 500
expect(res.status).toBeLessThan(500);
});
test("disables search for a collection", async ({ serverInfo }) => {
// First ensure it's enabled
await enableSearch(serverInfo, "pages");
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/enable", {
collection: "pages",
enabled: false,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.enabled).toBe(false);
});
test("enable requires collection name", async ({ serverInfo }) => {
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/enable", {
enabled: true,
});
expect(res.status).toBe(400);
});
});
test.describe("Search Rebuild API", () => {
test("rebuilds the index for a collection", async ({ serverInfo }) => {
await enableSearch(serverInfo, "posts");
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/rebuild", {
collection: "posts",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.data.indexed).toBe("number");
});
test("rebuild fails for collection without search enabled", async ({ serverInfo }) => {
// Disable search for pages first to ensure it's off
await apiRequest(serverInfo, "POST", "/_emdash/api/search/enable", {
collection: "pages",
enabled: false,
});
const res = await apiRequest(serverInfo, "POST", "/_emdash/api/search/rebuild", {
collection: "pages",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeDefined();
});
});
test.describe("Command Palette Content Search", () => {
test.fixme("shows content results when searching with enabled collections", async ({
// TODO: Command palette content search depends on suggest API which fails in dev
admin,
page,
serverInfo,
}) => {
// Enable search and rebuild index so content is findable
await enableSearch(serverInfo, "posts");
await apiRequest(serverInfo, "POST", "/_emdash/api/search/rebuild", {
collection: "posts",
});
await admin.goToDashboard();
// Open command palette
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Type a query matching seeded posts
await input.fill("First Post");
// Wait for the Content group to appear (debounced search + API call)
await expect(page.getByText("Content")).toBeVisible({ timeout: 10000 });
// Should show "First Post" in the content results
const contentResult = page
.locator("[class*='ResultItem']", { hasText: "First Post" })
.or(page.getByText("First Post").last());
await expect(contentResult).toBeVisible({ timeout: 5000 });
});
test.fixme("navigates to content editor when selecting a content result", async ({
// TODO: Command palette content search depends on suggest API which fails in dev
admin,
page,
serverInfo,
}) => {
// Enable search and rebuild
await enableSearch(serverInfo, "posts");
await apiRequest(serverInfo, "POST", "/_emdash/api/search/rebuild", {
collection: "posts",
});
await admin.goToDashboard();
// Open command palette
await page.keyboard.press(`${MOD_KEY}+k`);
const input = page.getByPlaceholder("Search pages and content...");
await expect(input).toBeVisible({ timeout: 5000 });
// Search for a specific post
await input.fill("Second Post");
// Wait for content results to load
await expect(page.getByText("Content")).toBeVisible({ timeout: 10000 });
// Find and click the result -- use keyboard Enter to select
// The first highlighted result should be a nav or content match
// Press ArrowDown to navigate to content results if needed
// Wait a moment for results to settle
await page.waitForTimeout(500);
// Press Enter to select the highlighted item, or click
const secondPost = page.getByText("Second Post").last();
await expect(secondPost).toBeVisible({ timeout: 5000 });
await secondPost.click();
// Should navigate to the content editor
await expect(page).toHaveURL(CONTENT_POSTS_URL_PATTERN, { timeout: 10000 });
});
});
});

View File

@@ -0,0 +1,192 @@
/**
* Settings Pages E2E Tests
*
* Tests the Social, SEO, and Email settings sub-pages.
* These are form-based pages that load settings from the API and save them.
*
* The primary class of bug we're catching: API response shape mismatches
* that crash the page on load, or save mutations that silently fail.
*/
import { test, expect } from "../fixtures";
// API patterns
const SETTINGS_API_PATTERN = /\/api\/settings$/;
test.describe("Social Settings", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("page renders with heading and form", async ({ admin, page }) => {
await admin.goto("/settings/social");
await admin.waitForShell();
await admin.waitForLoading();
// Page heading
await expect(page.locator("h1")).toContainText("Social Links");
// Should show the social profiles section
await expect(page.locator("text=Social Profiles")).toBeVisible({ timeout: 10000 });
});
test("displays all social input fields", async ({ admin, page }) => {
await admin.goto("/settings/social");
await admin.waitForShell();
await admin.waitForLoading();
// Each social field should have a visible input with its label
for (const label of ["Twitter", "GitHub", "Facebook", "Instagram", "LinkedIn", "YouTube"]) {
await expect(page.locator(`label:has-text("${label}")`)).toBeVisible({ timeout: 5000 });
}
// Save button should exist
await expect(page.locator("button", { hasText: "Save Social Links" })).toBeVisible();
});
test("saves a social link and persists across reload", async ({ admin, page }) => {
await admin.goto("/settings/social");
await admin.waitForShell();
await admin.waitForLoading();
const testHandle = `@e2e-test-${Date.now()}`;
// Fill the first social input field (Twitter)
const firstInput = page.locator("form input").first();
await firstInput.fill(testHandle);
// Wait for the save response
const saveResponse = page.waitForResponse(
(res) =>
SETTINGS_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 15000 },
);
// Click save
await page.locator("button", { hasText: "Save Social Links" }).click();
await saveResponse;
// Success banner should appear
await expect(page.locator("text=Social links saved")).toBeVisible({ timeout: 5000 });
// Reload the page
await admin.goto("/settings/social");
await admin.waitForShell();
await admin.waitForLoading();
// The value should persist
const firstInputAfterReload = page.locator("form input").first();
await expect(firstInputAfterReload).toHaveValue(testHandle, { timeout: 10000 });
});
});
test.describe("SEO Settings", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("page renders with heading and form", async ({ admin, page }) => {
await admin.goto("/settings/seo");
await admin.waitForShell();
await admin.waitForLoading();
// Page heading
await expect(page.locator("h1")).toContainText("SEO Settings");
// Should show the SEO section
await expect(page.locator("text=Search Engine Optimization")).toBeVisible({ timeout: 10000 });
});
test("displays expected SEO fields", async ({ admin, page }) => {
await admin.goto("/settings/seo");
await admin.waitForShell();
await admin.waitForLoading();
// Expected fields from SeoSettings.tsx
for (const label of [
"Title Separator",
"Google Verification",
"Bing Verification",
"robots.txt",
]) {
await expect(page.locator(`label:has-text("${label}")`)).toBeVisible({ timeout: 5000 });
}
// Save button
await expect(page.locator("button", { hasText: "Save SEO Settings" })).toBeVisible();
});
test("saves SEO settings and persists across reload", async ({ admin, page }) => {
await admin.goto("/settings/seo");
await admin.waitForShell();
await admin.waitForLoading();
const testVerification = `e2e-verify-${Date.now()}`;
// Fill the Google Verification field
const googleInput = page
.locator("label:has-text('Google Verification')")
.locator("..")
.locator("input");
await googleInput.fill(testVerification);
// Wait for save response
const saveResponse = page.waitForResponse(
(res) =>
SETTINGS_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
res.status() === 200,
{ timeout: 15000 },
);
// Click save
await page.locator("button", { hasText: "Save SEO Settings" }).click();
await saveResponse;
// Success banner
await expect(page.locator("text=SEO settings saved")).toBeVisible({ timeout: 5000 });
// Reload
await admin.goto("/settings/seo");
await admin.waitForShell();
await admin.waitForLoading();
// Value should persist
const googleInputAfterReload = page
.locator("label:has-text('Google Verification')")
.locator("..")
.locator("input");
await expect(googleInputAfterReload).toHaveValue(testVerification, { timeout: 10000 });
});
});
test.describe("Email Settings", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("page renders with heading and pipeline status", async ({ admin, page }) => {
await admin.goto("/settings/email");
await admin.waitForShell();
await admin.waitForLoading();
// Page heading
await expect(page.locator("h1")).toContainText("Email Settings");
// Should show the Email Pipeline section
await expect(page.locator("text=Email Pipeline")).toBeVisible({ timeout: 10000 });
});
test("shows pipeline section without crashing", async ({ admin, page }) => {
await admin.goto("/settings/email");
await admin.waitForShell();
await admin.waitForLoading();
// The Email Pipeline section heading should be visible
await expect(page.getByRole("heading", { name: "Email Pipeline" })).toBeVisible({
timeout: 10000,
});
});
});

View File

@@ -0,0 +1,106 @@
/**
* Setup Wizard E2E Tests
*
* Tests the first-run onboarding experience when the database is empty.
* Uses the dev-reset endpoint to clear setup state before each test.
*
* Note: The full setup flow requires passkey registration which can't be
* automated in browser tests. These tests cover the site and admin steps
* only. The "setup complete → redirect" test uses dev-bypass to simulate
* a completed setup.
*/
import { test, expect } from "../fixtures";
const BASE_URL = "http://localhost:4444";
const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
async function resetSetup(): Promise<void> {
const res = await fetch(`${BASE_URL}/_emdash/api/setup/dev-reset`, {
method: "POST",
headers: { "X-EmDash-Request": "1", Origin: BASE_URL },
});
if (!res.ok) {
throw new Error(`dev-reset failed (${res.status}): ${await res.text()}`);
}
}
async function restoreSetup(): Promise<void> {
const res = await fetch(`${BASE_URL}/_emdash/api/setup/dev-bypass?token=1`);
if (!res.ok) {
throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`);
}
}
test.describe("Setup Wizard", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async () => {
await resetSetup();
});
test.afterAll(async () => {
await restoreSetup();
});
test("redirects to setup wizard when database is empty", async ({ admin }) => {
await admin.goto("/");
await admin.expectSetupPage();
});
test("displays site step with form fields", async ({ admin }) => {
await admin.goToSetup();
await expect(admin.page.locator("text=Set up your site")).toBeVisible();
await expect(admin.page.getByLabel("Site Title")).toBeVisible();
await expect(admin.page.getByLabel("Tagline")).toBeVisible();
await expect(admin.page.getByRole("button", { name: "Continue" })).toBeVisible();
});
test("shows validation error when title is empty", async ({ admin }) => {
await admin.goToSetup();
await admin.page.getByRole("button", { name: "Continue" }).click();
await expect(admin.page.locator("text=Site title is required")).toBeVisible();
});
test("advances to admin step after filling site info", async ({ admin }) => {
await admin.goToSetup();
await admin.page.getByLabel("Site Title").fill("My Test Site");
await admin.page.getByLabel("Tagline").fill("A site for testing");
await admin.page.getByRole("button", { name: "Continue" }).click();
await expect(admin.page.locator("text=Create your account")).toBeVisible();
await expect(admin.page.getByLabel("Your Email")).toBeVisible();
await expect(admin.page.getByLabel("Your Name")).toBeVisible();
});
test("advances to passkey step after filling admin info", async ({ admin }) => {
await admin.goToSetup();
// Complete site step
await admin.page.getByLabel("Site Title").fill("My Test Site");
await admin.page.getByRole("button", { name: "Continue" }).click();
await expect(admin.page.locator("text=Create your account")).toBeVisible();
// Complete admin step
await admin.page.getByLabel("Your Email").fill("test@example.com");
await admin.page.getByLabel("Your Name").fill("Test User");
await admin.page.getByRole("button", { name: "Continue" }).click();
await expect(admin.page.locator("text=Secure your account")).toBeVisible();
await expect(admin.page.locator("text=Set up your passkey")).toBeVisible();
});
test("setup wizard not accessible after setup complete", async ({ admin }) => {
// Complete setup and authenticate via dev-bypass through the browser
await admin.devBypassAuth();
await admin.goToSetup();
// Should redirect to dashboard (setup already complete)
await admin.page.waitForURL(ADMIN_DASHBOARD_PATTERN, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,114 @@
/**
* Theme Marketplace E2E Tests
*
* Tests the theme marketplace admin pages:
* - Browse page at /themes/marketplace
* - Detail page at /themes/marketplace/{themeId}
*
* These tests run against a mock marketplace server (port 4445) that serves
* canned theme data.
*/
import { test, expect } from "../fixtures";
// URL patterns (module scope for e18e/prefer-static-regex)
const THEME_DETAIL_URL_PATTERN = /\/themes\/marketplace\/minimal-blog/;
const THEME_BROWSE_URL_PATTERN = /\/themes\/marketplace\/?$/;
test.describe("Theme Marketplace", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Browse page", () => {
test("renders theme cards", async ({ admin, page }) => {
await admin.goto("/themes/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
// Wait for at least one theme to load
await expect(page.getByText("Minimal Blog").first()).toBeVisible({ timeout: 15000 });
await expect(page.getByText("Portfolio Pro").first()).toBeVisible();
});
test("theme cards show name, author, and description", async ({ admin, page }) => {
await admin.goto("/themes/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.getByText("Minimal Blog").first()).toBeVisible({ timeout: 15000 });
// Author and description should be visible somewhere on the page
await expect(page.getByText("EmDash Labs").first()).toBeVisible();
await expect(page.getByText("A clean, minimal blog theme.")).toBeVisible();
});
test("search filters themes by name", async ({ admin, page }) => {
await admin.goto("/themes/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.getByText("Minimal Blog").first()).toBeVisible({ timeout: 15000 });
// Search for something that doesn't match
const searchInput = page.getByPlaceholder("Search themes...");
await searchInput.fill("nonexistent-theme-xyz");
// Wait for debounce
await page.waitForTimeout(1000);
// Neither theme should match
await expect(page.getByText("Minimal Blog").first()).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText("Portfolio Pro")).not.toBeVisible();
});
});
test.describe("Theme detail page", () => {
test("navigates to detail page on click", async ({ admin, page }) => {
await admin.goto("/themes/marketplace");
await admin.waitForShell();
await admin.waitForLoading();
// Wait for themes to load, then click
const themeLink = page.locator("a", { hasText: "Minimal Blog" }).first();
await expect(themeLink).toBeVisible({ timeout: 15000 });
await themeLink.click();
// URL should include the theme ID
await expect(page).toHaveURL(THEME_DETAIL_URL_PATTERN, { timeout: 10000 });
});
test("detail page shows theme info", async ({ admin, page }) => {
await admin.goto("/themes/marketplace/minimal-blog");
await admin.waitForShell();
await admin.waitForLoading();
// Theme name (use first() for multiple h1s)
await expect(page.locator("h1").first()).toContainText("Minimal Blog", {
timeout: 15000,
});
// Author
await expect(page.getByText("EmDash Labs").first()).toBeVisible();
// Description
await expect(page.getByText("A clean, minimal blog theme.")).toBeVisible();
});
test("back link navigates to browse", async ({ admin, page }) => {
await admin.goto("/themes/marketplace/minimal-blog");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.locator("h1").first()).toContainText("Minimal Blog", {
timeout: 15000,
});
// Click back link
const backLink = page.locator("a", { hasText: "Themes" }).first();
await backLink.click();
await expect(page).toHaveURL(THEME_BROWSE_URL_PATTERN, { timeout: 10000 });
});
});
});

View File

@@ -0,0 +1,320 @@
/**
* 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<void> {
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<void> {
// 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 <figure class="emdash-image"> with an <img>
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 });
});
});

305
e2e/tests/widgets.spec.ts Normal file
View File

@@ -0,0 +1,305 @@
/**
* Widgets E2E Tests
*
* Tests widget area management at /widgets.
* Covers creating widget areas, adding widgets, and deleting areas.
*
* The fixture starts with no widget areas, so tests create their own.
*/
import { test, expect } from "../fixtures";
// API helper
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
test.describe("Widgets", () => {
let headers: Record<string, string>;
let baseUrl: string;
test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();
baseUrl = serverInfo.baseUrl;
headers = apiHeaders(serverInfo.token, baseUrl);
});
test.describe("Widget Areas Page", () => {
test("renders the widgets page", async ({ admin, page }) => {
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Should show the page title
await expect(page.locator("h1").first()).toContainText("Widgets");
// Should show the "Add Widget Area" button
await expect(page.getByRole("button", { name: "Add Widget Area" })).toBeVisible();
// Should show the available widgets palette
await expect(page.locator("h2", { hasText: "Available Widgets" })).toBeVisible({
timeout: 10000,
});
});
test("shows empty state when no widget areas exist", async ({ admin, page }) => {
// Clean up any existing areas first
const res = await fetch(`${baseUrl}/_emdash/api/widget-areas`, { headers });
if (res.ok) {
const data: any = await res.json();
const areas = data.data?.areas ?? [];
for (const area of areas) {
await fetch(`${baseUrl}/_emdash/api/widget-areas/${area.name}`, {
method: "DELETE",
headers,
}).catch(() => {});
}
}
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Should show empty state
await expect(page.locator("text=No widget areas yet")).toBeVisible({ timeout: 10000 });
});
test("shows built-in widget types in the palette", async ({ admin, page }) => {
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Should show Content Block and Menu in the palette
await expect(page.locator("text=Content Block").first()).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=Menu").first()).toBeVisible();
});
});
test.describe("Create Widget Area", () => {
test("opens and closes the create widget area dialog", async ({ admin, page }) => {
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Click "Add Widget Area"
await page.getByRole("button", { name: "Add Widget Area" }).click();
// Dialog should appear
const dialog = page.getByRole("dialog", { name: "Create Widget Area" });
await expect(dialog).toBeVisible({ timeout: 5000 });
// Should have Name, Label, Description fields
await expect(dialog.getByLabel("Name")).toBeVisible();
await expect(dialog.getByLabel("Label")).toBeVisible();
// Cancel should close the dialog
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test("creates a new widget area", async ({ admin, page }) => {
const areaName = `e2e-area-${Date.now()}`;
const areaLabel = "E2E Test Area";
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Open create dialog
await page.getByRole("button", { name: "Add Widget Area" }).click();
const dialog = page.getByRole("dialog", { name: "Create Widget Area" });
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill form
await dialog.getByLabel("Name").fill(areaName);
await dialog.getByLabel("Label").fill(areaLabel);
await dialog.getByLabel("Description").fill("Area for E2E testing");
// Submit
await dialog.getByRole("button", { name: "Create" }).click();
// Dialog should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// New area should appear in the list
await expect(page.locator("h3", { hasText: areaLabel })).toBeVisible({ timeout: 10000 });
// Clean up via API
await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
});
test.describe("Add Widget to Area", () => {
test("adds a content widget to an area via API and verifies in UI", async ({ admin, page }) => {
const areaName = `e2e-widget-${Date.now()}`;
const areaLabel = "Widget Test Area";
// Create area via API
await fetch(`${baseUrl}/_emdash/api/widget-areas`, {
method: "POST",
headers,
body: JSON.stringify({ name: areaName, label: areaLabel }),
});
// Add a content widget via API
await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}/widgets`, {
method: "POST",
headers,
body: JSON.stringify({ type: "content", title: "Test Content Widget" }),
});
// Navigate to widgets page
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Area should be visible
await expect(page.locator("h3", { hasText: areaLabel })).toBeVisible({ timeout: 10000 });
// Widget should be visible in the area
await expect(page.locator("text=Test Content Widget").first()).toBeVisible({
timeout: 10000,
});
// Clean up
await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
test("deletes a widget from an area", async ({ admin, page }) => {
const areaName = `e2e-del-widget-${Date.now()}`;
const areaLabel = "Delete Widget Area";
// Create area and widget via API
await fetch(`${baseUrl}/_emdash/api/widget-areas`, {
method: "POST",
headers,
body: JSON.stringify({ name: areaName, label: areaLabel }),
});
const widgetRes = await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}/widgets`, {
method: "POST",
headers,
body: JSON.stringify({ type: "content", title: "Widget To Delete" }),
});
const widgetData: any = await widgetRes.json();
const widgetTitle = widgetData.data?.widget?.title ?? "Widget To Delete";
// Navigate to widgets page
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Widget should be visible
await expect(page.locator(`text=${widgetTitle}`).first()).toBeVisible({ timeout: 10000 });
// Click the delete button on the widget
const deleteButton = page.getByRole("button", { name: `Delete ${widgetTitle}` });
if (await deleteButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await deleteButton.click();
await admin.waitForLoading();
// Widget should disappear
await expect(page.locator(`text=${widgetTitle}`).first()).not.toBeVisible({
timeout: 10000,
});
}
// Clean up area
await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
});
test.describe("Delete Widget Area", () => {
test("deletes a widget area with confirmation", async ({ admin, page }) => {
const areaName = `e2e-del-area-${Date.now()}`;
const areaLabel = "Area To Delete";
// Create area via API
await fetch(`${baseUrl}/_emdash/api/widget-areas`, {
method: "POST",
headers,
body: JSON.stringify({ name: areaName, label: areaLabel }),
});
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
// Area should be visible
await expect(page.locator("h3", { hasText: areaLabel })).toBeVisible({ timeout: 10000 });
// Click the delete button on the area header
const deleteAreaButton = page.getByRole("button", {
name: `Delete ${areaLabel} widget area`,
});
await deleteAreaButton.click();
// ConfirmDialog should appear
const confirmDialog = page.getByRole("dialog", { name: "Delete Widget Area" });
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
// Confirm deletion
await confirmDialog.getByRole("button", { name: "Delete" }).click();
// Area should disappear
await expect(page.locator("h3", { hasText: areaLabel })).not.toBeVisible({
timeout: 10000,
});
});
test("cancel delete keeps the widget area", async ({ admin, page }) => {
const areaName = `e2e-keep-area-${Date.now()}`;
const areaLabel = "Area To Keep";
// Create area via API
await fetch(`${baseUrl}/_emdash/api/widget-areas`, {
method: "POST",
headers,
body: JSON.stringify({ name: areaName, label: areaLabel }),
});
await admin.goto("/widgets");
await admin.waitForShell();
await admin.waitForLoading();
await expect(page.locator("h3", { hasText: areaLabel })).toBeVisible({ timeout: 10000 });
// Click delete
await page.getByRole("button", { name: `Delete ${areaLabel} widget area` }).click();
// Dialog appears
const confirmDialog = page.getByRole("dialog", { name: "Delete Widget Area" });
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
// Cancel
await confirmDialog.getByRole("button", { name: "Cancel" }).click();
// Dialog closes
await expect(confirmDialog).not.toBeVisible({ timeout: 5000 });
// Area should still be there
await expect(page.locator("h3", { hasText: areaLabel })).toBeVisible();
// Clean up
await fetch(`${baseUrl}/_emdash/api/widget-areas/${areaName}`, {
method: "DELETE",
headers,
}).catch(() => {});
});
});
});

View File

@@ -0,0 +1,104 @@
/**
* WordPress Import Wizard E2E Tests
*
* Tests the WordPress import page at /import/wordpress.
* We can't test a full import without a real WP export file, but we
* verify the wizard loads, shows the expected UI elements, and handles
* basic validation.
*
* The wizard has two primary entry paths:
* 1. Enter a WordPress site URL (probe + connect)
* 2. Upload a WXR export file (.xml)
*
* Both paths are tested for initial rendering here.
*/
import { test, expect } from "../fixtures";
test.describe("WordPress Import", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test.describe("Page rendering", () => {
test("displays the import page with heading and step indicator", async ({ admin, page }) => {
await admin.goto("/import/wordpress");
await admin.waitForShell();
await admin.waitForLoading();
// Page heading
await admin.expectPageTitle("Import from WordPress");
// Subtitle
await expect(
page.getByText("Import posts, pages, and custom post types from WordPress"),
).toBeVisible({ timeout: 10000 });
});
test("does not crash or show error state", async ({ admin, page }) => {
await admin.goto("/import/wordpress");
await admin.waitForShell();
await admin.waitForLoading();
// No error overlays or crash states
await expect(page.locator("text=Failed to load")).not.toBeVisible();
await expect(page.locator("text=Something went wrong")).not.toBeVisible();
});
});
test.describe("URL input path", () => {
test("shows URL input field and Check Site button", async ({ admin, page }) => {
await admin.goto("/import/wordpress");
await admin.waitForShell();
await admin.waitForLoading();
// URL input section
await expect(page.locator("text=Enter your WordPress site URL")).toBeVisible({
timeout: 10000,
});
// Input field
const urlInput = page.locator('input[placeholder*="yoursite.com"]');
await expect(urlInput).toBeVisible();
// Check Site button (disabled by default since input is empty)
const checkButton = page.getByRole("button", { name: "Check Site" });
await expect(checkButton).toBeVisible();
await expect(checkButton).toBeDisabled();
});
test("Check Site button enables when URL is entered", async ({ admin, page }) => {
await admin.goto("/import/wordpress");
await admin.waitForShell();
await admin.waitForLoading();
const urlInput = page.locator('input[placeholder*="yoursite.com"]');
await urlInput.fill("https://example.com");
const checkButton = page.getByRole("button", { name: "Check Site" });
await expect(checkButton).toBeEnabled();
});
});
test.describe("File upload path", () => {
test("shows file upload drop zone with Browse button", async ({ admin, page }) => {
await admin.goto("/import/wordpress");
await admin.waitForShell();
await admin.waitForLoading();
// "or upload directly" divider
await expect(page.locator("text=or upload directly")).toBeVisible({ timeout: 10000 });
// Upload section text
await expect(page.locator("text=Upload WordPress export file")).toBeVisible();
await expect(page.locator("text=Drag and drop or click to browse")).toBeVisible();
// Browse Files button (it's a styled label wrapping a hidden file input)
await expect(page.locator("text=Browse Files")).toBeVisible();
// Hidden file input should accept .xml
const fileInput = page.locator('input[type="file"][accept=".xml"]');
await expect(fileInput).toBeAttached();
});
});
});