Files
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

425 lines
12 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { ensureBuilt } from "../server.js";
interface SiteCase {
name: string;
dir: string;
port: number;
startupTimeoutMs: number;
waitPath?: string;
setupPath?: string | null;
frontendPath?: string;
frontendStatuses?: number[];
requireDoctype?: boolean;
}
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
const execAsync = promisify(execFile);
const SITE_MATRIX: SiteCase[] = [
{
name: "demos/playground",
dir: resolve(WORKSPACE_ROOT, "demos/playground"),
port: 4603,
startupTimeoutMs: 120_000,
waitPath: "/playground",
frontendPath: "/playground",
requireDoctype: false,
},
// Templates
{
name: "templates/blog",
dir: resolve(WORKSPACE_ROOT, "templates/blog"),
port: 4612,
startupTimeoutMs: 60_000,
},
{
name: "templates/blog-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/blog-cloudflare"),
port: 4613,
startupTimeoutMs: 120_000,
},
{
name: "templates/marketing",
dir: resolve(WORKSPACE_ROOT, "templates/marketing"),
port: 4614,
startupTimeoutMs: 90_000,
},
{
name: "templates/marketing-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/marketing-cloudflare"),
port: 4615,
startupTimeoutMs: 120_000,
},
{
name: "templates/portfolio",
dir: resolve(WORKSPACE_ROOT, "templates/portfolio"),
port: 4616,
startupTimeoutMs: 90_000,
},
{
name: "templates/portfolio-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/portfolio-cloudflare"),
port: 4617,
startupTimeoutMs: 120_000,
},
{
name: "templates/starter-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/starter-cloudflare"),
port: 4618,
startupTimeoutMs: 120_000,
},
];
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(3000),
});
if (res.status > 0) return;
} catch {
// retry
}
await new Promise((resolveSleep) => setTimeout(resolveSleep, 500));
}
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
}
async function fetchWithRetry(url: string, retries = 10, delayMs = 1500): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
if (res.status < 500) return res;
lastError = new Error(`${url} returned ${res.status}`);
} catch (error) {
lastError = error;
}
if (attempt < retries) {
await new Promise((resolveSleep) => setTimeout(resolveSleep, delayMs));
}
}
throw lastError instanceof Error ? lastError : new Error(`Request failed for ${url}`);
}
// ---------------------------------------------------------------------------
// Build verification — runs a single recursive `pnpm build` across templates
// and the playground demo in parallel.
// ---------------------------------------------------------------------------
describe("Site build verification", () => {
it("all templates and playground build successfully", { timeout: 300_000 }, async () => {
await ensureBuilt();
try {
await execAsync(
"pnpm",
[
"run",
"--recursive",
"--filter",
"{./templates/*}",
"--filter",
"@emdash-cms/playground",
"build",
],
{
cwd: WORKSPACE_ROOT,
timeout: 240_000,
env: {
...process.env,
CI: "true",
},
},
);
} catch (error) {
const stderr =
error instanceof Error && "stderr" in error ? (error as { stderr: string }).stderr : "";
const stdout =
error instanceof Error && "stdout" in error ? (error as { stdout: string }).stdout : "";
throw new Error(`Site builds failed:\n\n${stderr || stdout}`.slice(0, 5000), {
cause: error,
});
}
});
});
// ---------------------------------------------------------------------------
// Helpers — shared server lifecycle for runtime tests
// ---------------------------------------------------------------------------
interface BootedServer {
baseUrl: string;
process: ReturnType<typeof spawn>;
output: string;
}
async function bootSite(site: SiteCase): Promise<BootedServer> {
await ensureBuilt();
// Remove stale database files so each run starts fresh.
for (const file of ["data.db", "data.db-wal", "data.db-shm"]) {
rmSync(join(site.dir, file), { force: true });
}
const baseUrl = `http://localhost:${site.port}`;
const serverProcess = spawn("pnpm", ["exec", "astro", "dev", "--port", String(site.port)], {
cwd: site.dir,
env: {
...process.env,
CI: "true",
},
stdio: "pipe",
});
let output = "";
serverProcess.stdout?.on("data", (data: Buffer) => {
output += data.toString();
});
serverProcess.stderr?.on("data", (data: Buffer) => {
output += data.toString();
});
const waitPath = site.waitPath ?? "/_emdash/admin/";
await waitForServer(`${baseUrl}${waitPath}`, site.startupTimeoutMs);
return {
baseUrl,
process: serverProcess,
get output() {
return output;
},
};
}
function killServer(serverProcess: ReturnType<typeof spawn>): Promise<void> {
serverProcess.kill("SIGTERM");
return new Promise((done) => {
setTimeout(() => {
if (!serverProcess.killed) {
serverProcess.kill("SIGKILL");
}
setTimeout(done, 500);
}, 1200);
});
}
// ---------------------------------------------------------------------------
// Runtime verification — boots each site with `astro dev` and checks that
// admin + frontend respond.
// ---------------------------------------------------------------------------
describe.sequential("Site runtime verification", () => {
for (const site of SITE_MATRIX) {
const setupPath = site.setupPath ?? "/_emdash/api/setup/dev-bypass?redirect=/";
const frontendPath = site.frontendPath ?? "/";
const frontendStatuses = site.frontendStatuses ?? [200, 302, 307, 308];
const requireDoctype = site.requireDoctype ?? true;
it(
`${site.name} boots and serves admin + frontend`,
{ timeout: site.startupTimeoutMs + 120_000 },
async () => {
const server = await bootSite(site);
try {
if (setupPath) {
const setupRes = await fetchWithRetry(`${server.baseUrl}${setupPath}`);
expect(setupRes.status).toBeLessThan(500);
}
const adminRes = await fetchWithRetry(`${server.baseUrl}/_emdash/admin/`);
expect(adminRes.status).toBeLessThan(500);
const frontendRes = await fetchWithRetry(`${server.baseUrl}${frontendPath}`);
expect(frontendStatuses).toContain(frontendRes.status);
const body = await frontendRes.text();
if (requireDoctype) {
expect(body).toContain("<!DOCTYPE html>");
}
} catch (error) {
throw new Error(
`${site.name} smoke failed: ${error instanceof Error ? error.message : String(error)}\n\n` +
server.output.slice(-3000),
{ cause: error },
);
} finally {
await killServer(server.process);
}
},
);
}
});
// ---------------------------------------------------------------------------
// MCP endpoint verification — boots one Node and one Cloudflare site, gets a
// bearer token, and verifies the MCP server responds to tools/list.
// ---------------------------------------------------------------------------
const MCP_SITES: SiteCase[] = SITE_MATRIX.filter(
(s) => s.name === "templates/blog" || s.name === "templates/starter-cloudflare",
);
describe.sequential("MCP endpoint verification", () => {
for (const site of MCP_SITES) {
it(
`${site.name} MCP tools/list responds with tools`,
{ timeout: site.startupTimeoutMs + 120_000 },
async () => {
const server = await bootSite(site);
try {
// Run dev-bypass with ?token=1 to get a bearer token
const setupRes = await fetchWithRetry(
`${server.baseUrl}/_emdash/api/setup/dev-bypass?token=1`,
);
expect(setupRes.status).toBeLessThan(500);
const setupBody = (await setupRes.json()) as {
data?: { token?: string };
};
const token = setupBody.data?.token;
expect(token).toBeTruthy();
// Send MCP initialize
const initRes = await fetch(`${server.baseUrl}/_emdash/api/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "smoke-test", version: "1.0" },
},
id: 1,
}),
});
expect(initRes.status).toBe(200);
// Parse SSE response to extract JSON
const initText = await initRes.text();
const initData = parseSSE(initText);
expect(initData).toHaveProperty("result.serverInfo.name", "emdash");
// Send initialized notification + tools/list in one request
// (stateless mode — each request is independent, so we send
// the full sequence: notifications/initialized then tools/list)
const listRes = await fetch(`${server.baseUrl}/_emdash/api/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify([
{
jsonrpc: "2.0",
method: "notifications/initialized",
},
{
jsonrpc: "2.0",
method: "tools/list",
params: {},
id: 2,
},
]),
});
expect(listRes.status).toBe(200);
const listText = await listRes.text();
const listData = parseSSE(listText);
expect(listData).toHaveProperty("result.tools");
const tools = (listData as { result: { tools: unknown[] } }).result.tools;
expect(tools.length).toBeGreaterThan(0);
// Verify some expected tools exist
const toolNames = tools.map((t: unknown) => (t as { name: string }).name);
expect(toolNames).toContain("content_list");
expect(toolNames).toContain("schema_list_collections");
// Send 14 concurrent tools/list calls and verify all succeed —
// guards against an auth-middleware race observed in production
// where parallel requests on the same authenticated session
// occasionally returned spurious 401s. The InMemoryTransport
// integration test cannot reach this code path; only a live
// HTTP server exercises the auth middleware that's racy.
const concurrentResponses = await Promise.all(
Array.from({ length: 14 }, (_, i) =>
fetch(`${server.baseUrl}/_emdash/api/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify([
{ jsonrpc: "2.0", method: "notifications/initialized" },
{
jsonrpc: "2.0",
method: "tools/list",
params: {},
id: 100 + i,
},
]),
}),
),
);
const statusCodes = concurrentResponses.map((r) => r.status);
const failedStatuses = statusCodes.filter((s) => s !== 200);
expect(
failedStatuses,
`expected all 14 concurrent calls to return 200; got: ${statusCodes.join(",")}`,
).toEqual([]);
} catch (error) {
throw new Error(
`${site.name} MCP smoke failed: ${error instanceof Error ? error.message : String(error)}\n\n` +
server.output.slice(-3000),
{ cause: error },
);
} finally {
await killServer(server.process);
}
},
);
}
});
/**
* Parse the first JSON-RPC message from an SSE text response.
* MCP stateless mode returns `event: message\ndata: {...}\n\n`.
*/
function parseSSE(text: string): unknown {
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
return JSON.parse(line.slice(6));
}
}
// Fall back to parsing as plain JSON (non-SSE response)
return JSON.parse(text);
}