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:
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