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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

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

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

View File

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

View File

@@ -0,0 +1,24 @@
/**
* Re-runs dev-bypass after a dev-reset so the server info file has a valid PAT
* and the fixture database is back in "setup complete" state.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json");
export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise<void> {
const res = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`);
if (!res.ok) {
throw new Error(`dev-bypass failed (${res.status}): ${await res.text()}`);
}
const json: { data: { token?: string } } = await res.json();
const token = json.data.token;
if (!token) throw new Error("dev-bypass did not return a PAT token");
// Update the server info so subsequent tests use the fresh token
const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
info.token = token;
writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2));
}

View File

@@ -0,0 +1,33 @@
/**
* Chrome DevTools virtual WebAuthn authenticator for passkey e2e.
* Chromium-only (CDP). See https://developer.chrome.com/docs/devtools/webauthn/
*/
import type { Page } from "@playwright/test";
export async function addVirtualWebAuthnAuthenticator(page: Page): Promise<() => Promise<void>> {
const session = await page.context().newCDPSession(page);
await session.send("WebAuthn.enable");
const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
return async () => {
try {
await session.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
} catch {
// session may already be closed
}
try {
await session.detach();
} catch {
// ignore
}
};
}