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 { 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 { 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; output: string; } async function bootSite(site: SiteCase): Promise { 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): Promise { 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(""); } } 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); }