/** * E2E tests for CLI commands against a real Astro dev server. * * Shells out to the actual `emdash` binary with --url and --token * flags, verifying real command output and exit codes. * * Runs by default. Requires built artifacts (auto-builds if missing). */ import { execFile } from "node:child_process"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { TestServerContext } from "../server.js"; import { assertNodeVersion, createTestServer } from "../server.js"; const exec = promisify(execFile); const PORT = 4398; // Different port from client integration tests // Path to the built CLI binary const CLI_BIN = resolve(import.meta.dirname, "../../../dist/cli/index.mjs"); describe("CLI Integration", () => { let ctx: TestServerContext; beforeAll(async () => { assertNodeVersion(); ctx = await createTestServer({ port: PORT }); }); afterAll(async () => { await ctx?.cleanup(); }); /** Run an emdash CLI command and return stdout */ async function cli(...args: string[]): Promise { const { stdout } = await exec( "node", [CLI_BIN, ...args, "--url", ctx.baseUrl, "--token", ctx.token, "--json"], { timeout: 15_000, }, ); return stdout; } /** Run CLI and parse JSON output */ async function cliJson(...args: string[]): Promise { const stdout = await cli(...args); return JSON.parse(stdout) as T; } // ----------------------------------------------------------------------- // Schema commands // ----------------------------------------------------------------------- describe("schema", () => { it("lists collections", async () => { const result = await cliJson<{ slug: string }[]>("schema", "list"); expect(Array.isArray(result)).toBe(true); const slugs = result.map((c) => c.slug); expect(slugs).toContain("posts"); expect(slugs).toContain("pages"); }); it("gets a single collection", async () => { const result = await cliJson<{ slug: string; label: string }>("schema", "get", "posts"); expect(result.slug).toBe("posts"); expect(result.label).toBe("Posts"); }); it("creates and deletes a collection", async () => { const created = await cliJson<{ slug: string }>( "schema", "create", "cli_temp", "--label", "CLI Temp", ); expect(created.slug).toBe("cli_temp"); // Verify it exists const list = await cliJson<{ slug: string }[]>("schema", "list"); expect(list.map((c) => c.slug)).toContain("cli_temp"); // Delete await cli("schema", "delete", "cli_temp", "--force"); // Verify it's gone const listAfter = await cliJson<{ slug: string }[]>("schema", "list"); expect(listAfter.map((c) => c.slug)).not.toContain("cli_temp"); }); it("adds and removes fields", async () => { // Create a temp collection await cli("schema", "create", "cli_fields", "--label", "Fields Test"); // Add a field const field = await cliJson<{ slug: string; type: string }>( "schema", "add-field", "cli_fields", "name", "--type", "string", "--label", "Name", ); expect(field.slug).toBe("name"); expect(field.type).toBe("string"); // Remove the field await cli("schema", "remove-field", "cli_fields", "name"); // Clean up await cli("schema", "delete", "cli_fields", "--force"); }); }); // ----------------------------------------------------------------------- // Content commands // ----------------------------------------------------------------------- describe("content", () => { it("lists content", async () => { const result = await cliJson<{ items: { data: Record }[] }>( "content", "list", "posts", ); expect(result.items.length).toBeGreaterThanOrEqual(2); }); it("gets content by id", async () => { const postId = ctx.contentIds["posts"]![0]!; const result = await cliJson<{ data: { title: string } }>("content", "get", "posts", postId); expect(result.data.title).toBe("First Post"); }); it("creates, updates, and deletes content", async () => { // Create const created = await cliJson<{ id: string; slug: string }>( "content", "create", "posts", "--data", JSON.stringify({ title: "CLI Post", excerpt: "From CLI" }), "--slug", "cli-post", ); expect(created.id).toBeDefined(); expect(created.slug).toBe("cli-post"); // Update (get first to obtain _rev, then update with it) const fetched = await cliJson<{ _rev: string }>("content", "get", "posts", created.id); const updated = await cliJson<{ data: { title: string } }>( "content", "update", "posts", created.id, "--rev", fetched._rev, "--data", JSON.stringify({ title: "Updated CLI Post" }), ); expect(updated.data.title).toBe("Updated CLI Post"); // Delete await cli("content", "delete", "posts", created.id); }); it("publishes and unpublishes content", async () => { const item = await cliJson<{ id: string }>( "content", "create", "posts", "--data", JSON.stringify({ title: "Pub Test" }), ); await cli("content", "publish", "posts", item.id); await cli("content", "unpublish", "posts", item.id); // Clean up await cli("content", "delete", "posts", item.id); }); }); // ----------------------------------------------------------------------- // Content lifecycle: schedule and restore // ----------------------------------------------------------------------- describe("content lifecycle", () => { it("schedules content for publishing", async () => { const item = await cliJson<{ id: string }>( "content", "create", "posts", "--data", JSON.stringify({ title: "CLI Schedule Test" }), ); // Schedule does not produce JSON output, just a success message await cli("content", "schedule", "posts", item.id, "--at", "2027-06-01T09:00:00Z"); // Verify via get const fetched = await cliJson<{ scheduledAt: string }>("content", "get", "posts", item.id); expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00Z"); // Clean up await cli("content", "delete", "posts", item.id); }); it("restores a trashed item", async () => { const item = await cliJson<{ id: string }>( "content", "create", "posts", "--data", JSON.stringify({ title: "CLI Restore Test" }), ); // Delete (soft trash) await cli("content", "delete", "posts", item.id); // Restore await cli("content", "restore", "posts", item.id); // Should be accessible again (auto-published before deletion, so restored as published) const fetched = await cliJson<{ status: string }>("content", "get", "posts", item.id); expect(fetched.status).toBe("published"); // Final cleanup await cli("content", "delete", "posts", item.id); }); }); // ----------------------------------------------------------------------- // Media commands // ----------------------------------------------------------------------- describe("media", () => { it("uploads, lists, gets, and deletes media", async () => { // Create a temp file to upload const { writeFileSync } = await import("node:fs"); const { join } = await import("node:path"); const { tmpdir } = await import("node:os"); // 1x1 PNG pixel const pngBytes = 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, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const tmpFile = join(tmpdir(), "emdash-cli-test.png"); writeFileSync(tmpFile, pngBytes); // Upload const uploaded = await cliJson<{ id: string; filename: string }>( "media", "upload", tmpFile, "--alt", "CLI test image", ); expect(uploaded.id).toBeDefined(); expect(uploaded.filename).toBe("emdash-cli-test.png"); // List const list = await cliJson<{ items: { id: string }[] }>("media", "list"); const ids = list.items.map((m) => m.id); expect(ids).toContain(uploaded.id); // Get const fetched = await cliJson<{ id: string; filename: string }>("media", "get", uploaded.id); expect(fetched.id).toBe(uploaded.id); // Delete await cli("media", "delete", uploaded.id); // Clean up temp file const { unlinkSync } = await import("node:fs"); unlinkSync(tmpFile); }); }); // ----------------------------------------------------------------------- // Search command // ----------------------------------------------------------------------- describe("search", () => { it("searches content", async () => { // Search should work even if no results (the command shouldn't error) const result = await cliJson("search", "First Post"); expect(Array.isArray(result)).toBe(true); }); }); // ----------------------------------------------------------------------- // Auth commands // ----------------------------------------------------------------------- describe("auth", () => { it("whoami returns user info with token auth", async () => { const result = await cliJson<{ email: string; role: string }>("whoami"); expect(result.email).toBe("dev@emdash.local"); expect(result.role).toBe("admin"); }); }); // ----------------------------------------------------------------------- // Taxonomy commands // ----------------------------------------------------------------------- describe("taxonomy", () => { it("taxonomy list returns valid JSON array", async () => { const result = await cliJson<{ name: string }[]>("taxonomy", "list"); expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThanOrEqual(1); const names = result.map((t) => t.name); expect(names).toContain("categories"); }); it("taxonomy terms returns terms for a taxonomy", async () => { const result = await cliJson<{ terms: { slug: string }[] }>( "taxonomy", "terms", "categories", ); expect(result.terms).toBeDefined(); expect(Array.isArray(result.terms)).toBe(true); const slugs = result.terms.map((t) => t.slug); expect(slugs).toContain("news"); }); }); // ----------------------------------------------------------------------- // Menu commands // ----------------------------------------------------------------------- describe("menu", () => { it("menu list returns valid JSON array", async () => { const result = await cliJson("menu", "list"); expect(Array.isArray(result)).toBe(true); }); }); });