b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
/**
|
|
* Integration test server helper.
|
|
*
|
|
* Bootstraps an isolated Astro dev server from a minimal fixture,
|
|
* runs setup, seeds test data, and creates auth tokens. Each test
|
|
* suite gets a fresh database and server process.
|
|
*
|
|
* Usage:
|
|
*
|
|
* const ctx = await createTestServer({ port: 4399 });
|
|
* // ctx.client — EmDashClient (devBypass auth)
|
|
* // ctx.token — PAT bearer token for CLI tests
|
|
* // ctx.baseUrl — http://localhost:4399
|
|
* // ctx.cwd — working directory of the running server
|
|
* await ctx.cleanup();
|
|
*/
|
|
|
|
import { execFile, spawn } from "node:child_process";
|
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { EmDashClient } from "../../src/client/index.js";
|
|
|
|
const execAsync = promisify(execFile);
|
|
|
|
// Test regex patterns
|
|
const SESSION_COOKIE_REGEX = /^([^;]+)/;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FIXTURE_DIR = resolve(import.meta.dirname, "fixture");
|
|
// Borrow node_modules from demos/simple — it has all the deps we need
|
|
// and is maintained by pnpm workspace resolution.
|
|
const DONOR_NODE_MODULES = resolve(import.meta.dirname, "../../../../demos/simple/node_modules");
|
|
// Parent dir for per-suite fixture copies. In-repo (not os.tmpdir()) so the
|
|
// project root shares a real path tree with emdash's source — see
|
|
// createTestServer's doc comment. Gitignored via tests/integration/.gitignore.
|
|
const SERVERS_BASE = resolve(import.meta.dirname, ".servers");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestServerOptions {
|
|
port: number;
|
|
/** Server startup timeout in ms (default: 90_000) */
|
|
timeout?: number;
|
|
/** Seed test data after setup (default: true) */
|
|
seed?: boolean;
|
|
/** Additional environment variables to pass to the dev server */
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
export interface TestServerContext {
|
|
/** Base URL of the running server */
|
|
baseUrl: string;
|
|
/** Working directory containing the fixture */
|
|
cwd: string;
|
|
/** EmDashClient authenticated via dev-bypass session */
|
|
client: EmDashClient;
|
|
/** PAT bearer token with full scopes (for CLI / raw fetch tests) */
|
|
token: string;
|
|
/** Seeded collection slugs */
|
|
collections: string[];
|
|
/** Seeded content IDs keyed by collection */
|
|
contentIds: Record<string, string[]>;
|
|
/** Session cookie string for raw fetch calls needing session auth */
|
|
sessionCookie: string;
|
|
/** Stop the server and remove the temp directory */
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node.js version guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Astro requires Node.js >= 22.12.0. Call from a `beforeAll` to fail the
|
|
* suite immediately when the environment is misconfigured rather than
|
|
* silently skipping.
|
|
*/
|
|
export function assertNodeVersion(): void {
|
|
const [major, minor] = process.versions.node.split(".").map(Number) as [number, number];
|
|
const ok = major! > 22 || (major === 22 && minor! >= 12);
|
|
if (!ok) {
|
|
throw new Error(
|
|
`Integration tests require Node.js >= 22.12.0 (running ${process.versions.node}). ` +
|
|
`Update your Node version instead of skipping tests.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../..");
|
|
const CLI_BINARY = resolve(import.meta.dirname, "../../dist/cli/index.mjs");
|
|
|
|
let buildPromise: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Ensure the workspace is built before starting integration tests.
|
|
* Runs `pnpm build` once (cached across test suites via module-level promise).
|
|
* Skips if the CLI binary already exists.
|
|
*/
|
|
export function ensureBuilt(): Promise<void> {
|
|
if (!buildPromise) {
|
|
buildPromise = doBuild();
|
|
}
|
|
return buildPromise;
|
|
}
|
|
|
|
async function doBuild(): Promise<void> {
|
|
if (existsSync(CLI_BINARY)) return;
|
|
|
|
console.log("[integration] Built artifacts missing — running pnpm build...");
|
|
await execAsync("pnpm", ["build"], {
|
|
cwd: WORKSPACE_ROOT,
|
|
timeout: 120_000,
|
|
});
|
|
console.log("[integration] Build complete.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
// Any HTTP response (even 500) means the server is up.
|
|
// We only keep waiting on connection errors (caught below).
|
|
if (res.status > 0) return;
|
|
} catch {
|
|
// Server not ready yet — connection refused / timeout
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
|
|
}
|
|
|
|
/**
|
|
* Create an Astro dev server for integration testing.
|
|
*
|
|
* Each server runs from its own copied fixture root. Astro's "another dev
|
|
* server is already running" guard locks on the project root
|
|
* (`<root>/.astro/dev.json`), not the port, so suites that share one fixture
|
|
* dir collide when vitest runs them in parallel (#1604). A per-suite root keeps
|
|
* the lock isolated and parallelism intact.
|
|
*
|
|
* Two non-obvious constraints on that copy:
|
|
* - Source files are copied (not symlinked) so the project root resolves to a
|
|
* real path — symlinking the root broke Astro virtual module resolution.
|
|
* - The copy lives inside the repo (next to this file), not in `os.tmpdir()`.
|
|
* `emdash`'s `.astro` UI components (e.g. Comments.astro) resolve through
|
|
* the symlinked `node_modules` back to their real path under `packages/core`.
|
|
* With a root under `/tmp`, Astro mis-joins that real path onto the temp
|
|
* root ("/tmp/.../home/.../Comments.astro") and SSR 500s with "No cached
|
|
* compile metadata found". A root sharing the real path tree avoids it.
|
|
*/
|
|
export async function createTestServer(options: TestServerOptions): Promise<TestServerContext> {
|
|
const { port, timeout = 90_000, seed = true } = options;
|
|
const baseUrl = `http://localhost:${port}`;
|
|
|
|
// --- 0. Ensure workspace is built ---
|
|
await ensureBuilt();
|
|
|
|
// --- 1. Copy the fixture into a private per-suite root (in-repo, see above) ---
|
|
mkdirSync(SERVERS_BASE, { recursive: true });
|
|
const workDir = mkdtempSync(join(SERVERS_BASE, "srv-"));
|
|
// Real copy of the source fixture (everything but node_modules, which is
|
|
// large and provided via symlink below).
|
|
cpSync(FIXTURE_DIR, workDir, {
|
|
recursive: true,
|
|
filter: (src) => !src.split(/[\\/]/).includes("node_modules"),
|
|
});
|
|
const dbPath = join(workDir, "test.db");
|
|
const uploadsDir = join(workDir, "uploads");
|
|
mkdirSync(uploadsDir, { recursive: true });
|
|
|
|
// Borrow the donor node_modules via symlink (resolution still hits real
|
|
// paths, which is why astro.config.mjs sets vite.server.fs.strict: false).
|
|
symlinkSync(DONOR_NODE_MODULES, join(workDir, "node_modules"));
|
|
|
|
// --- 2. Start dev server ---
|
|
const astroBin = join(workDir, "node_modules", ".bin", "astro");
|
|
const server = spawn(astroBin, ["dev", "--port", String(port)], {
|
|
cwd: workDir,
|
|
env: {
|
|
...process.env,
|
|
// Force foreground mode: `astro dev` otherwise detects coding-agent
|
|
// environments and re-spawns itself as a detached background process,
|
|
// which our SIGTERM cleanup can't reach (leaking the port). With this
|
|
// set, the spawned process *is* the server, matching CI behavior.
|
|
ASTRO_DEV_BACKGROUND: "1",
|
|
EMDASH_TEST_DB: `file:${dbPath}`,
|
|
EMDASH_TEST_UPLOADS: uploadsDir,
|
|
...options.env,
|
|
},
|
|
stdio: "pipe",
|
|
});
|
|
|
|
// Always capture server output. Forward to stderr when DEBUG is set,
|
|
// and always keep a ring buffer of the last 5 KB for error reporting.
|
|
let serverOutput = "";
|
|
const MAX_OUTPUT = 5000;
|
|
function appendOutput(chunk: string): void {
|
|
if (process.env.DEBUG) process.stderr.write(`[integration:${port}] ${chunk}`);
|
|
serverOutput += chunk;
|
|
if (serverOutput.length > MAX_OUTPUT * 2) {
|
|
serverOutput = serverOutput.slice(-MAX_OUTPUT);
|
|
}
|
|
}
|
|
server.stdout?.on("data", (data: Buffer) => appendOutput(data.toString()));
|
|
server.stderr?.on("data", (data: Buffer) => appendOutput(data.toString()));
|
|
|
|
// Track for cleanup
|
|
let stopped = false;
|
|
|
|
async function cleanup(): Promise<void> {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
|
|
server.kill("SIGTERM");
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
|
|
// Force kill if still alive
|
|
if (!server.killed) {
|
|
server.kill("SIGKILL");
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
|
|
// Remove the temp fixture root (source copy, DB, uploads, and the
|
|
// node_modules symlink — rmSync removes the link, not the target).
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
|
|
try {
|
|
// --- 3. Wait for server to be ready ---
|
|
await waitForServer(`${baseUrl}/_emdash/api/setup/dev-bypass`, timeout);
|
|
|
|
// --- 4. Run setup + create PAT in one request ---
|
|
// The ?token query param tells the dev-bypass endpoint to also
|
|
// create a PAT with full scopes and return it in the response.
|
|
const setupRes = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`);
|
|
if (!setupRes.ok) {
|
|
const body = await setupRes.text().catch(() => "");
|
|
throw new Error(`Setup bypass failed (${setupRes.status}): ${body}`);
|
|
}
|
|
const setupJson = (await setupRes.json()) as {
|
|
data: { user: { id: string; email: string }; token?: string };
|
|
};
|
|
const setupData = setupJson.data;
|
|
const token = setupData.token;
|
|
if (!token) {
|
|
throw new Error("Setup bypass did not return a PAT token");
|
|
}
|
|
|
|
// Extract session cookie for raw fetch calls that need session auth
|
|
const setCookie = setupRes.headers.get("set-cookie");
|
|
let sessionCookie = "";
|
|
if (setCookie) {
|
|
const match = setCookie.match(SESSION_COOKIE_REGEX);
|
|
if (match) sessionCookie = match[1]!;
|
|
}
|
|
|
|
// --- 5. Create client authenticated via PAT ---
|
|
const client = new EmDashClient({
|
|
baseUrl,
|
|
token,
|
|
});
|
|
|
|
// --- 8. Seed test data ---
|
|
const collections: string[] = [];
|
|
const contentIds: Record<string, string[]> = {};
|
|
|
|
if (seed) {
|
|
await seedTestData(client, collections, contentIds);
|
|
}
|
|
|
|
return {
|
|
baseUrl,
|
|
cwd: workDir,
|
|
client,
|
|
token,
|
|
collections,
|
|
contentIds,
|
|
sessionCookie,
|
|
cleanup,
|
|
};
|
|
} catch (error) {
|
|
// Include server output in error for CI debugging
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
await cleanup();
|
|
throw new Error(
|
|
`${msg}\n\nServer output (last ${MAX_OUTPUT} chars):\n${serverOutput.slice(-MAX_OUTPUT)}`,
|
|
{
|
|
cause: error,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Seeds sample content into the test server.
|
|
*
|
|
* Collections and fields are created by the seed file
|
|
* (fixture/.emdash/seed.json) during dev-bypass setup.
|
|
* This function only creates content entries.
|
|
*
|
|
* Content:
|
|
* - posts: 3 items (2 published, 1 draft)
|
|
* - pages: 2 items (1 published, 1 draft)
|
|
*/
|
|
async function seedTestData(
|
|
client: EmDashClient,
|
|
collections: string[],
|
|
contentIds: Record<string, string[]>,
|
|
): Promise<void> {
|
|
collections.push("posts");
|
|
collections.push("pages");
|
|
|
|
const postIds: string[] = [];
|
|
|
|
const post1 = await client.create("posts", {
|
|
data: {
|
|
title: "First Post",
|
|
body: "Hello **world**. This is the first post.",
|
|
excerpt: "The very first post",
|
|
},
|
|
slug: "first-post",
|
|
});
|
|
postIds.push(post1.id);
|
|
await client.publish("posts", post1.id);
|
|
|
|
const post2 = await client.create("posts", {
|
|
data: {
|
|
title: "Second Post",
|
|
body: "A second post with a [link](https://example.com).",
|
|
excerpt: "Another post",
|
|
},
|
|
slug: "second-post",
|
|
});
|
|
postIds.push(post2.id);
|
|
await client.publish("posts", post2.id);
|
|
|
|
const post3 = await client.create("posts", {
|
|
data: {
|
|
title: "Draft Post",
|
|
body: "This post is still a draft.",
|
|
excerpt: "Not published yet",
|
|
},
|
|
slug: "draft-post",
|
|
});
|
|
postIds.push(post3.id);
|
|
|
|
contentIds["posts"] = postIds;
|
|
|
|
const pageIds: string[] = [];
|
|
|
|
const page1 = await client.create("pages", {
|
|
data: {
|
|
title: "About",
|
|
body: "# About Us\n\nWe are a **test** fixture.",
|
|
},
|
|
slug: "about",
|
|
});
|
|
pageIds.push(page1.id);
|
|
await client.publish("pages", page1.id);
|
|
|
|
const page2 = await client.create("pages", {
|
|
data: {
|
|
title: "Contact",
|
|
body: "Get in touch.",
|
|
},
|
|
slug: "contact",
|
|
});
|
|
pageIds.push(page2.id);
|
|
|
|
contentIds["pages"] = pageIds;
|
|
}
|