Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
114
e2e/fixture/.emdash/seed.json
Normal file
114
e2e/fixture/.emdash/seed.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
e2e/fixture/astro.config.mjs
Normal file
40
e2e/fixture/astro.config.mjs
Normal 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 "@emdash-cms/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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
40
e2e/fixture/emdash-env.d.ts
vendored
Normal file
40
e2e/fixture/emdash-env.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
body?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
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;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
10
e2e/fixture/noop-sandbox.mjs
Normal file
10
e2e/fixture/noop-sandbox.mjs
Normal 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
16
e2e/fixture/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "emdash-e2e-fixture",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@emdash-cms/auth": "workspace:*",
|
||||
"@emdash-cms/plugin-color": "workspace:*",
|
||||
"astro": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"emdash": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
}
|
||||
}
|
||||
6
e2e/fixture/src/live.config.ts
Normal file
6
e2e/fixture/src/live.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
21
e2e/fixture/src/pages/index.astro
Normal file
21
e2e/fixture/src/pages/index.astro
Normal 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>
|
||||
21
e2e/fixture/src/pages/posts/[slug].astro
Normal file
21
e2e/fixture/src/pages/posts/[slug].astro
Normal 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
605
e2e/fixtures/admin.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
BIN
e2e/fixtures/assets/test-image.png
Normal file
BIN
e2e/fixtures/assets/test-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
54
e2e/fixtures/index.ts
Normal file
54
e2e/fixtures/index.ts
Normal 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";
|
||||
328
e2e/fixtures/mock-marketplace.ts
Normal file
328
e2e/fixtures/mock-marketplace.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
24
e2e/fixtures/refresh-server-pat.ts
Normal file
24
e2e/fixtures/refresh-server-pat.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Re-runs dev-bypass after a dev-reset so the server info file has a valid PAT
|
||||
* and the fixture database is back in "setup complete" state.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
|
||||
|
||||
export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise<void> {
|
||||
const res = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
const json: { data: { token?: string } } = await res.json();
|
||||
const token = json.data.token;
|
||||
if (!token) throw new Error("dev-bypass did not return a PAT token");
|
||||
|
||||
// Update the server info so subsequent tests use the fresh token
|
||||
const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
|
||||
info.token = token;
|
||||
writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2));
|
||||
}
|
||||
33
e2e/fixtures/virtual-authenticator.ts
Normal file
33
e2e/fixtures/virtual-authenticator.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Chrome DevTools virtual WebAuthn authenticator for passkey e2e.
|
||||
* Chromium-only (CDP). See https://developer.chrome.com/docs/devtools/webauthn/
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function addVirtualWebAuthnAuthenticator(page: Page): Promise<() => Promise<void>> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send("WebAuthn.enable");
|
||||
const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
},
|
||||
});
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await session.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
|
||||
} catch {
|
||||
// session may already be closed
|
||||
}
|
||||
try {
|
||||
await session.detach();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
331
e2e/global-setup.ts
Normal file
331
e2e/global-setup.ts
Normal 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 @emdash-cms/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
63
e2e/global-teardown.ts
Normal 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.");
|
||||
}
|
||||
146
e2e/tests/accessibility.spec.ts
Normal file
146
e2e/tests/accessibility.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
382
e2e/tests/admin-fixes.spec.ts
Normal file
382
e2e/tests/admin-fixes.spec.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 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 keeps edited field values after save completes", 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.getByRole("status", { name: "Autosave status" })).toContainText("Saved", {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Regression: autosave should not snap the input back to older cached server state.
|
||||
await expect(titleInput).toHaveValue("Autosave Perf Test Edit");
|
||||
await page.waitForTimeout(500);
|
||||
await expect(titleInput).toHaveValue("Autosave Perf Test Edit");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
236
e2e/tests/allowed-domains.spec.ts
Normal file
236
e2e/tests/allowed-domains.spec.ts
Normal 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(() => {});
|
||||
});
|
||||
});
|
||||
226
e2e/tests/api-tokens.spec.ts
Normal file
226
e2e/tests/api-tokens.spec.ts
Normal 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
239
e2e/tests/auth.spec.ts
Normal 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
130
e2e/tests/autosave.spec.ts
Normal 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
143
e2e/tests/bylines.spec.ts
Normal 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
287
e2e/tests/comments.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
572
e2e/tests/content-actions.spec.ts
Normal file
572
e2e/tests/content-actions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
164
e2e/tests/content-crud.spec.ts
Normal file
164
e2e/tests/content-crud.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
277
e2e/tests/content-types.spec.ts
Normal file
277
e2e/tests/content-types.spec.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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("Save Content Type Settings", () => {
|
||||
test("toggling a feature and saving persists across reloads", async ({ admin }) => {
|
||||
await admin.goto("/content-types/posts");
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
|
||||
const toggleLabel = admin.page.locator("label", { hasText: "Enable comments" });
|
||||
const saveButton = admin.page.getByRole("button", { name: "Save Changes" });
|
||||
|
||||
// On initial load there are no unsaved changes
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Flip the toggle -- Save should enable
|
||||
await toggleLabel.click();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save: the PUT must return 200 and no failure toast should render
|
||||
const savePut = admin.page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/schema/collections/posts") && res.request().method() === "PUT",
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await saveButton.click();
|
||||
expect((await savePut).status()).toBe(200);
|
||||
await expect(admin.page.getByText("Failed to save")).not.toBeVisible();
|
||||
|
||||
// Reload -- the saved change is reflected server-side, so the editor
|
||||
// loads with no unsaved diff
|
||||
await admin.page.reload();
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Restore the original toggle state so the shared DB used by other E2E
|
||||
// tests (e.g. comments.spec.ts) isn't left with commentsEnabled flipped.
|
||||
await toggleLabel.click();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
const restorePut = admin.page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/schema/collections/posts") && res.request().method() === "PUT",
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await saveButton.click();
|
||||
expect((await restorePut).status()).toBe(200);
|
||||
await expect(admin.page.getByText("Failed to save")).not.toBeVisible();
|
||||
await admin.page.reload();
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
143
e2e/tests/device-auth.spec.ts
Normal file
143
e2e/tests/device-auth.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
242
e2e/tests/field-widgets.spec.ts
Normal file
242
e2e/tests/field-widgets.spec.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
165
e2e/tests/form-data-loss.spec.ts
Normal file
165
e2e/tests/form-data-loss.spec.ts
Normal 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
307
e2e/tests/i18n.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
e2e/tests/invite-flow.spec.ts
Normal file
244
e2e/tests/invite-flow.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Invite Flow E2E Tests
|
||||
*
|
||||
* Tests the full user invitation lifecycle:
|
||||
* - Invite accept page error states (missing token, invalid token)
|
||||
* - Admin creating an invite via API
|
||||
* - Full invite → passkey registration → user creation flow
|
||||
* using a CDP virtual WebAuthn authenticator
|
||||
*
|
||||
* The invite accept page (/_emdash/admin/invite/accept) is a public
|
||||
* route — auth middleware allows unauthenticated access.
|
||||
*
|
||||
* In dev mode the built-in console email provider auto-activates,
|
||||
* so invite creation sends an email (captured in memory) rather than
|
||||
* returning the invite URL directly. We retrieve the URL from the
|
||||
* dev emails endpoint using server-side fetch with the PAT.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, test } from "../fixtures";
|
||||
import { addVirtualWebAuthnAuthenticator } from "../fixtures/virtual-authenticator";
|
||||
|
||||
// Regex patterns
|
||||
const ADMIN_URL_PATTERN = /\/_emdash\/admin/;
|
||||
const INVITE_URL_REGEX = /https?:\/\/[^\s]+\/admin\/invite\/accept\?token=[^\s]+/;
|
||||
const URL_IN_TEXT_REGEX = /https?:\/\/[^\s]+/;
|
||||
|
||||
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
|
||||
|
||||
function getServerInfo(): { baseUrl: string; token: string; sessionCookie: string } {
|
||||
return JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invite via the API using the PAT from serverInfo.
|
||||
* Uses Node.js fetch (not browser) to avoid module isolation issues
|
||||
* with the dev email store.
|
||||
*
|
||||
* When the dev console email provider is active, the invite email is
|
||||
* captured in memory. We retrieve it via GET /_emdash/api/dev/emails.
|
||||
*/
|
||||
async function createInviteViaApi(email: string, role = 30): Promise<string> {
|
||||
const { baseUrl, token, sessionCookie } = getServerInfo();
|
||||
|
||||
// Clear previously captured emails
|
||||
await fetch(`${baseUrl}/_emdash/api/dev/emails`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-EmDash-Request": "1",
|
||||
Cookie: sessionCookie,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the invite
|
||||
const createRes = await fetch(`${baseUrl}/_emdash/api/auth/invite`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-EmDash-Request": "1",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ email, role }),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
throw new Error(`Invite creation failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
|
||||
const createBody = (await createRes.json()) as {
|
||||
data?: { inviteUrl?: string };
|
||||
};
|
||||
|
||||
// If no email provider, the response includes the URL directly
|
||||
if (createBody.data?.inviteUrl) {
|
||||
return createBody.data.inviteUrl;
|
||||
}
|
||||
|
||||
// Otherwise, retrieve the invite URL from captured dev emails
|
||||
const emailsRes = await fetch(`${baseUrl}/_emdash/api/dev/emails`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!emailsRes.ok) {
|
||||
throw new Error(`Dev emails endpoint failed (${emailsRes.status}): ${await emailsRes.text()}`);
|
||||
}
|
||||
|
||||
const emailsBody = (await emailsRes.json()) as {
|
||||
data?: { items?: Array<{ message: { text: string } }> };
|
||||
};
|
||||
|
||||
const emails = emailsBody.data?.items;
|
||||
if (!emails?.length) {
|
||||
throw new Error("No emails captured by dev console provider after invite creation");
|
||||
}
|
||||
|
||||
const latestEmail = emails[0]!;
|
||||
const match = latestEmail.message.text.match(URL_IN_TEXT_REGEX);
|
||||
if (!match) {
|
||||
throw new Error(`No URL found in invite email text: ${latestEmail.message.text}`);
|
||||
}
|
||||
|
||||
return match[0];
|
||||
}
|
||||
|
||||
test.describe("Invite Accept Page", () => {
|
||||
test.describe("Error states", () => {
|
||||
test("shows error when no token is provided", async ({ admin }) => {
|
||||
await admin.page.goto("/_emdash/admin/invite/accept");
|
||||
await admin.waitForHydration();
|
||||
|
||||
await expect(admin.page.locator("h1")).toContainText("Invite Error", { timeout: 15000 });
|
||||
await expect(admin.page.locator("text=No invite token provided")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error for invalid token", async ({ admin }) => {
|
||||
await admin.page.goto("/_emdash/admin/invite/accept?token=bogus-token-12345");
|
||||
await admin.waitForHydration();
|
||||
|
||||
await expect(admin.page.locator("h1")).toContainText("Invite Error", { timeout: 15000 });
|
||||
|
||||
// The error step renders an h2 with an error title and a
|
||||
// "Back to login" link regardless of the specific error code.
|
||||
await expect(admin.page.locator("h2")).toBeVisible({ timeout: 15000 });
|
||||
await expect(admin.page.locator("text=Back to login")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows back to login link on error", async ({ admin }) => {
|
||||
await admin.page.goto("/_emdash/admin/invite/accept");
|
||||
await admin.waitForHydration();
|
||||
|
||||
await expect(admin.page.locator("h1")).toContainText("Invite Error", { timeout: 15000 });
|
||||
await expect(admin.page.locator("text=Back to login")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Valid invite token", () => {
|
||||
test("shows registration form with email and role", async ({ admin }) => {
|
||||
const inviteUrl = await createInviteViaApi("invite-ui@example.com", 30);
|
||||
const token = new URL(inviteUrl).searchParams.get("token")!;
|
||||
|
||||
await admin.page.goto(`/_emdash/admin/invite/accept?token=${token}`);
|
||||
await admin.waitForHydration();
|
||||
|
||||
await expect(admin.page.locator("h1")).toContainText("Accept Invite", { timeout: 15000 });
|
||||
await expect(admin.page.locator("text=You've been invited!")).toBeVisible();
|
||||
await expect(admin.page.getByLabel("Email")).toHaveValue("invite-ui@example.com");
|
||||
await expect(admin.page.locator("text=AUTHOR")).toBeVisible();
|
||||
await expect(admin.page.locator("text=Create your passkey")).toBeVisible();
|
||||
await expect(admin.page.getByRole("button", { name: "Create Account" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Invite creation via API", () => {
|
||||
test("admin can create an invite and get invite URL", async () => {
|
||||
const inviteUrl = await createInviteViaApi("api-test@example.com", 20);
|
||||
|
||||
expect(inviteUrl).toMatch(INVITE_URL_REGEX);
|
||||
|
||||
const parsed = new URL(inviteUrl);
|
||||
expect(parsed.searchParams.get("token")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("invite URL contains the admin invite accept path", async () => {
|
||||
const inviteUrl = await createInviteViaApi("prefix-test@example.com");
|
||||
|
||||
expect(inviteUrl).toContain("/admin/invite/accept");
|
||||
});
|
||||
|
||||
test("creating invite for existing user returns error", async () => {
|
||||
const { baseUrl, token } = getServerInfo();
|
||||
|
||||
const res = await fetch(`${baseUrl}/_emdash/api/auth/invite`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-EmDash-Request": "1",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ email: "dev@emdash.local", role: 30 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Full invite flow with passkey registration", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("completes invite registration with virtual authenticator", async ({ admin, page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Step 1: Create invite via server-side API
|
||||
const inviteUrl = await createInviteViaApi("invited-user@example.com", 30);
|
||||
const inviteToken = new URL(inviteUrl).searchParams.get("token")!;
|
||||
|
||||
// Step 2: Set up virtual authenticator
|
||||
const removeAuth = await addVirtualWebAuthnAuthenticator(page);
|
||||
|
||||
try {
|
||||
// Step 3: Navigate to invite accept page
|
||||
await page.goto(`/_emdash/admin/invite/accept?token=${inviteToken}`);
|
||||
await admin.waitForHydration();
|
||||
|
||||
// Step 4: Verify the registration form renders
|
||||
await expect(page.locator("h1")).toContainText("Accept Invite", { timeout: 15000 });
|
||||
await expect(page.locator("text=You've been invited!")).toBeVisible();
|
||||
await expect(page.getByLabel("Email")).toHaveValue("invited-user@example.com");
|
||||
await expect(page.locator("text=AUTHOR")).toBeVisible();
|
||||
|
||||
// Step 5: Fill in name and click Create Account
|
||||
const nameInput = page.getByLabel("Your name (optional)");
|
||||
await nameInput.fill("Invited User");
|
||||
|
||||
await page.getByRole("button", { name: "Create Account" }).click();
|
||||
|
||||
// Step 6: Wait for passkey flow to complete and redirect
|
||||
await expect(page).toHaveURL(ADMIN_URL_PATTERN, { timeout: 60_000 });
|
||||
|
||||
// Verify no passkey errors appeared
|
||||
await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0);
|
||||
await expect(page.locator("text=Invalid origin")).toHaveCount(0);
|
||||
} finally {
|
||||
await removeAuth();
|
||||
}
|
||||
});
|
||||
|
||||
test("invited user appears in the users list", async ({ admin, page }) => {
|
||||
await admin.devBypassAuth();
|
||||
await admin.goto("/users");
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
|
||||
await expect(page.locator("text=invited-user@example.com")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
117
e2e/tests/keyboard-shortcuts.spec.ts
Normal file
117
e2e/tests/keyboard-shortcuts.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
115
e2e/tests/marketplace.spec.ts
Normal file
115
e2e/tests/marketplace.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
137
e2e/tests/media-library.spec.ts
Normal file
137
e2e/tests/media-library.spec.ts
Normal 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
204
e2e/tests/menus.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
75
e2e/tests/passkey-full-setup-virtual-auth.spec.ts
Normal file
75
e2e/tests/passkey-full-setup-virtual-auth.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* End-to-end passkey registration with a CDP virtual authenticator (no human).
|
||||
* Runs against the default fixture URL (http://localhost:4444).
|
||||
*
|
||||
* If this fails, the passkey stack (options → create → verify) is broken on localhost.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, test } from "../fixtures";
|
||||
import { refreshServerPatAfterDevBypass } from "../fixtures/refresh-server-pat";
|
||||
import { addVirtualWebAuthnAuthenticator } from "../fixtures/virtual-authenticator";
|
||||
|
||||
const ADMIN_AFTER_SETUP_URL = /\/_emdash\/admin(\/login)?/;
|
||||
|
||||
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
|
||||
|
||||
function fixtureBaseUrl(): string {
|
||||
return JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8")).baseUrl as string;
|
||||
}
|
||||
|
||||
async function resetSetup(): Promise<void> {
|
||||
const base = fixtureBaseUrl();
|
||||
const res = await fetch(`${base}/_emdash/api/setup/dev-reset`, {
|
||||
method: "POST",
|
||||
headers: { "X-EmDash-Request": "1", Origin: base },
|
||||
});
|
||||
if (!res.ok) throw new Error(`dev-reset failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function restoreFixtureSetup(): Promise<void> {
|
||||
await refreshServerPatAfterDevBypass(fixtureBaseUrl());
|
||||
}
|
||||
|
||||
test.describe("Setup wizard passkey with virtual authenticator (localhost)", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetSetup();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await restoreFixtureSetup();
|
||||
});
|
||||
|
||||
test("completes full setup including passkey registration", async ({ admin, page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const removeAuth = await addVirtualWebAuthnAuthenticator(page);
|
||||
|
||||
try {
|
||||
await admin.goToSetup();
|
||||
|
||||
await page.getByLabel("Site Title").fill("Virtual Auth Site");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Your Email").fill("virtual-auth@example.com");
|
||||
await page.getByLabel("Your Name").fill("Virtual Auth User");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.locator("text=Choose how to sign in")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create Passkey" }).click();
|
||||
|
||||
// admin-verify creates the user but does not set a session; wizard sends user to /_emdash/admin and auth redirects to login.
|
||||
await expect(page).toHaveURL(ADMIN_AFTER_SETUP_URL, { timeout: 60_000 });
|
||||
await expect(page.locator("text=Choose how to sign in")).toHaveCount(0);
|
||||
await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0);
|
||||
await expect(page.locator("text=Invalid origin")).toHaveCount(0);
|
||||
} finally {
|
||||
await removeAuth();
|
||||
}
|
||||
});
|
||||
});
|
||||
228
e2e/tests/plugins.spec.ts
Normal file
228
e2e/tests/plugins.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
300
e2e/tests/redirect-loop-detection.spec.ts
Normal file
300
e2e/tests/redirect-loop-detection.spec.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Redirect Loop Detection E2E Tests
|
||||
*
|
||||
* Tests write-time loop prevention, pattern-aware detection,
|
||||
* cache behavior, and admin UI warnings.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures";
|
||||
|
||||
function apiHeaders(token: string, baseUrl: string) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-EmDash-Request": "1",
|
||||
Origin: baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a redirect via API, return the id */
|
||||
async function create(
|
||||
page: import("@playwright/test").Page,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
options?: { enabled?: boolean },
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, {
|
||||
headers: apiHeaders(token, baseUrl),
|
||||
data: { source, destination, ...options },
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok()) {
|
||||
return body.error?.message ?? "unknown error";
|
||||
}
|
||||
return body.data.id;
|
||||
}
|
||||
|
||||
/** Try to create a redirect, expect rejection. Return error message. */
|
||||
async function createExpectError(
|
||||
page: import("@playwright/test").Page,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, {
|
||||
headers: apiHeaders(token, baseUrl),
|
||||
data: { source, destination },
|
||||
});
|
||||
expect(res.ok(), `Expected rejection for ${source} → ${destination}`).toBe(false);
|
||||
const body = await res.json();
|
||||
return body.error?.message ?? "unknown error";
|
||||
}
|
||||
|
||||
/** Try to create a redirect, expect success */
|
||||
async function createExpectSuccess(
|
||||
page: import("@playwright/test").Page,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, {
|
||||
headers: apiHeaders(token, baseUrl),
|
||||
data: { source, destination },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.ok(), `Expected success for ${source} → ${destination}: ${JSON.stringify(body)}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete all redirects */
|
||||
async function cleanup(
|
||||
page: import("@playwright/test").Page,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
const res = await page.request.get(`${baseUrl}/_emdash/api/redirects`, { headers });
|
||||
if (!res.ok()) return;
|
||||
const data = await res.json();
|
||||
for (const item of data.data?.items ?? []) {
|
||||
await page.request.delete(`${baseUrl}/_emdash/api/redirects/${item.id}`, { headers });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("redirect loop detection", () => {
|
||||
test.beforeEach(async ({ admin, page, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
await cleanup(page, serverInfo.baseUrl, serverInfo.token);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, serverInfo }) => {
|
||||
await cleanup(page, serverInfo.baseUrl, serverInfo.token);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pattern template loops
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("rejects matching pattern template loop: [...path]", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/old/[...path]", "/new/[...path]");
|
||||
const msg = await createExpectError(page, baseUrl, token, "/new/[...path]", "/old/[...path]");
|
||||
expect(msg).toContain("loop");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin UI warnings
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("admin UI shows no loop banner when no loops exist", async ({ admin, page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/one", "/two");
|
||||
await createExpectSuccess(page, baseUrl, token, "/two", "/three");
|
||||
|
||||
await admin.goto("/redirects");
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
|
||||
await expect(page.locator("text=Redirect loop detected")).toBeHidden();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Error message format
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("error message shows template names, not __p__ dummy values", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/blog/[slug]", "/articles/[slug]");
|
||||
const msg = await createExpectError(page, baseUrl, token, "/articles/hello", "/blog/hello");
|
||||
expect(msg).not.toContain("__p__");
|
||||
expect(msg).toContain("/articles/hello");
|
||||
expect(msg).toContain("/blog/hello");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update-time loop detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("rejects update that would create a loop", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
await createExpectSuccess(page, baseUrl, token, "/a", "/b");
|
||||
await createExpectSuccess(page, baseUrl, token, "/b", "/c");
|
||||
const id = await create(page, baseUrl, token, "/c", "/d");
|
||||
|
||||
const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { destination: "/a" },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.error?.message).toContain("loop");
|
||||
});
|
||||
|
||||
test("allows update that does not create a loop", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
await createExpectSuccess(page, baseUrl, token, "/a", "/b");
|
||||
const id = await create(page, baseUrl, token, "/b", "/c");
|
||||
|
||||
const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { destination: "/d" },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects update changing both source and destination to create a loop", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
await createExpectSuccess(page, baseUrl, token, "/a", "/b");
|
||||
const id = await create(page, baseUrl, token, "/x", "/y");
|
||||
|
||||
const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { source: "/b", destination: "/a" },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.error?.message).toContain("loop");
|
||||
});
|
||||
|
||||
test("rejects update changing destination + enabling simultaneously", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
await createExpectSuccess(page, baseUrl, token, "/a", "/b");
|
||||
const id = await create(page, baseUrl, token, "/b", "/safe", { enabled: false });
|
||||
|
||||
const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { destination: "/a", enabled: true },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.error?.message).toContain("loop");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("disabled redirect does not participate in loop detection", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
const id = await create(page, baseUrl, token, "/a", "/b");
|
||||
await createExpectSuccess(page, baseUrl, token, "/b", "/c");
|
||||
|
||||
await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
await createExpectSuccess(page, baseUrl, token, "/c", "/a");
|
||||
});
|
||||
|
||||
test("re-enabling a disabled redirect that creates a loop is allowed", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
// Users who had redirects before upgrade should be able to toggle
|
||||
// them freely. The warning banner alerts them to the loop.
|
||||
const { baseUrl, token } = serverInfo;
|
||||
const headers = apiHeaders(token, baseUrl);
|
||||
await createExpectSuccess(page, baseUrl, token, "/a", "/b");
|
||||
await createExpectSuccess(page, baseUrl, token, "/b", "/c");
|
||||
const id = await create(page, baseUrl, token, "/c", "/a", { enabled: false });
|
||||
|
||||
const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Advanced pattern combinations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("rejects pattern with different param names that still loops", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/blog/[slug]", "/articles/[slug]");
|
||||
const msg = await createExpectError(page, baseUrl, token, "/articles/[id]", "/blog/[id]");
|
||||
expect(msg).toContain("loop");
|
||||
});
|
||||
|
||||
test("rejects catch-all loop even with deep nesting", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/v1/[...path]", "/v2/[...path]");
|
||||
const msg = await createExpectError(
|
||||
page,
|
||||
baseUrl,
|
||||
token,
|
||||
"/v2/api/users/[slug]",
|
||||
"/v1/api/users/[slug]",
|
||||
);
|
||||
expect(msg).toContain("loop");
|
||||
});
|
||||
|
||||
test("multiple overlapping catch-alls: more specific loops back", async ({
|
||||
page,
|
||||
serverInfo,
|
||||
}) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
await createExpectSuccess(page, baseUrl, token, "/a/[...path]", "/b/[...path]");
|
||||
await createExpectSuccess(page, baseUrl, token, "/a/sub/[...path]", "/c/[...path]");
|
||||
const msg = await createExpectError(page, baseUrl, token, "/c/[...path]", "/a/sub/[...path]");
|
||||
expect(msg).toContain("loop");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Long chains (20+)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test("rejects loop at the end of a 25-redirect chain", async ({ page, serverInfo }) => {
|
||||
const { baseUrl, token } = serverInfo;
|
||||
for (let i = 1; i <= 24; i++) {
|
||||
await createExpectSuccess(page, baseUrl, token, `/r${i}`, `/r${i + 1}`);
|
||||
}
|
||||
const msg = await createExpectError(page, baseUrl, token, "/r25", "/r1");
|
||||
expect(msg).toContain("loop");
|
||||
expect(msg).toContain("/r1");
|
||||
expect(msg).toContain("/r25");
|
||||
});
|
||||
});
|
||||
143
e2e/tests/redirects.spec.ts
Normal file
143
e2e/tests/redirects.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
363
e2e/tests/revisions.spec.ts
Normal file
363
e2e/tests/revisions.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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.getByRole("status", { name: "Autosave status" })).toContainText("Saved", {
|
||||
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-start").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);
|
||||
});
|
||||
});
|
||||
452
e2e/tests/search.spec.ts
Normal file
452
e2e/tests/search.spec.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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 is publicly accessible", async ({ serverInfo }) => {
|
||||
// The LiveSearch component is shipped for public-site use and calls this
|
||||
// endpoint without credentials. The query layer hardcodes status='published',
|
||||
// so anonymous callers can only see published content.
|
||||
const res = await fetch(`${serverInfo.baseUrl}/_emdash/api/search?q=test`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("search admin endpoints still require authentication", async ({ serverInfo }) => {
|
||||
// Admin-only: enable, rebuild, stats must stay gated even though the
|
||||
// read endpoint is public.
|
||||
const stats = await fetch(`${serverInfo.baseUrl}/_emdash/api/search/stats`);
|
||||
expect([401, 403]).toContain(stats.status);
|
||||
|
||||
const enable = await fetch(`${serverInfo.baseUrl}/_emdash/api/search/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ collection: "posts" }),
|
||||
});
|
||||
expect([401, 403]).toContain(enable.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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
229
e2e/tests/settings-pages.spec.ts
Normal file
229
e2e/tests/settings-pages.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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. Two are rendered (sticky header + bottom-of-form,
|
||||
// both submit the same form via `form="social-settings-form"`); use .first()
|
||||
// to avoid Playwright strict-mode locator violations.
|
||||
await expect(page.locator("button", { hasText: "Save Social Links" }).first()).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. Two buttons match (sticky header + bottom-of-form); either
|
||||
// submits the same form, so use .first() for strict-mode compatibility.
|
||||
await page.locator("button", { hasText: "Save Social Links" }).first().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. Two are rendered (sticky header + bottom-of-form, both submit
|
||||
// the same form via `form="seo-settings-form"`); use .first() to avoid
|
||||
// Playwright strict-mode locator violations.
|
||||
await expect(page.locator("button", { hasText: "Save SEO Settings" }).first()).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. Two buttons match (sticky header + bottom-of-form); either
|
||||
// submits the same form, so use .first() for strict-mode compatibility.
|
||||
await page.locator("button", { hasText: "Save SEO Settings" }).first().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("Language Switcher", () => {
|
||||
test.beforeEach(async ({ admin }) => {
|
||||
await admin.devBypassAuth();
|
||||
});
|
||||
|
||||
test("settings page shows language select", async ({ admin, page }) => {
|
||||
await admin.goto("/settings");
|
||||
await admin.waitForShell();
|
||||
|
||||
const languageSelect = page.locator('[aria-label="Language"]');
|
||||
await expect(languageSelect).toBeVisible();
|
||||
});
|
||||
|
||||
test("switching language updates the UI", async ({ admin, page }) => {
|
||||
await admin.goto("/settings");
|
||||
await admin.waitForShell();
|
||||
|
||||
// Switch to German
|
||||
await page.locator('[aria-label="Language"]').click();
|
||||
await page.locator("[role='option']", { hasText: "Deutsch" }).click();
|
||||
|
||||
await expect(page.locator("h1")).toContainText("Einstellungen", { timeout: 5000 });
|
||||
|
||||
// Switch back — the select now shows "Deutsch" as its value
|
||||
await page.locator("[role='combobox']", { hasText: "Deutsch" }).click();
|
||||
await page.locator("[role='option']", { hasText: "English" }).click();
|
||||
|
||||
await expect(page.locator("h1")).toContainText("Settings", { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
104
e2e/tests/setup-wizard.spec.ts
Normal file
104
e2e/tests/setup-wizard.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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";
|
||||
import { refreshServerPatAfterDevBypass } from "../fixtures/refresh-server-pat";
|
||||
|
||||
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> {
|
||||
await refreshServerPatAfterDevBypass(BASE_URL);
|
||||
}
|
||||
|
||||
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=Choose how to sign in")).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 });
|
||||
});
|
||||
});
|
||||
114
e2e/tests/theme-marketplace.spec.ts
Normal file
114
e2e/tests/theme-marketplace.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
320
e2e/tests/visual-editing.spec.ts
Normal file
320
e2e/tests/visual-editing.spec.ts
Normal 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
305
e2e/tests/widgets.spec.ts
Normal 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(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
104
e2e/tests/wordpress-import.spec.ts
Normal file
104
e2e/tests/wordpress-import.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user