feat(FN-3765): add run panel and generation pipeline for CLI printing press

Adds a CLI command generation pipeline with a runner to execute generated commands, run/artifact API routes, and a TestRunnerPanel UI for monitoring execution — complete with tests covering the generator, runner, run routes, and the panel component.

Fusion-Task-Id: FN-3765
This commit is contained in:
Fusion
2026-05-10 17:26:06 -07:00
committed by gsxdsm
parent 49370b99c5
commit e466df5a76
21 changed files with 897 additions and 32 deletions

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CliPrintingPressTestRunner } from "../run/TestRunnerPanel";
import type { ServiceDraft } from "../wizard/types";
vi.mock("lucide-react", () => ({
Play: () => null,
RefreshCw: () => null,
CheckCircle2: () => null,
AlertTriangle: () => null,
}));
function makeDraft(): ServiceDraft {
const now = new Date().toISOString();
return {
id: "draft-1",
name: "Demo",
slug: "demo",
description: "",
baseUrl: "https://example.com",
transport: "http",
endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping", params: "q" }],
credential: { kind: "none" },
createdAt: now,
updatedAt: now,
};
}
describe("CliPrintingPressTestRunner", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("submits run request and renders output states", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/run")) {
return {
ok: true,
json: async () => ({ stdout: "ok", stderr: "", exitCode: 0, durationMs: 12, timedOut: false, argv: ["demo.mjs", "--endpoint", "e1", "--q", "***"] }),
};
}
if (url.endsWith("/regenerate")) {
return { ok: true, json: async () => ({ draft: makeDraft(), artifact: { draftId: "draft-1", slug: "demo", binPath: "/tmp/demo.mjs", entrypoint: "node", generatedAt: new Date().toISOString() } }) };
}
return { ok: false, status: 404, json: async () => ({ error: "missing" }) };
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<CliPrintingPressTestRunner draftId="draft-1" draft={makeDraft()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.type(screen.getByLabelText("q"), "hello");
await user.type(screen.getByPlaceholderText("api_key"), "secret-value");
await user.click(screen.getByRole("button", { name: /^Run$/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/run"), expect.objectContaining({ method: "POST" })));
const runCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/run"));
expect(runCall).toBeTruthy();
expect(JSON.parse(String((runCall?.[1] as RequestInit).body))).toMatchObject({ endpointId: "e1", params: { q: "hello" } });
expect(await screen.findByText("ok")).toBeTruthy();
expect(screen.queryByText("secret-value")).toBeNull();
});
it("renders error and timeout output states", async () => {
let calls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/run")) {
calls += 1;
if (calls === 1) {
return {
ok: true,
json: async () => ({ stdout: "", stderr: "boom", exitCode: 1, durationMs: 22, timedOut: false, argv: ["demo.mjs"] }),
};
}
return {
ok: true,
json: async () => ({ stdout: "", stderr: "", exitCode: null, durationMs: 33, timedOut: true, argv: ["demo.mjs"] }),
};
}
return { ok: false, status: 404, json: async () => ({ error: "missing" }) };
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<CliPrintingPressTestRunner draftId="draft-1" draft={makeDraft()} />);
await user.click(screen.getByRole("button", { name: /^Run$/i }));
expect(await screen.findByText("boom")).toBeTruthy();
await user.click(screen.getByRole("button", { name: /^Run$/i }));
expect(await screen.findByText("Failed")).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { access, mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { generateCli } from "../generation/generator.js";
import { createDraftStore, getArtifactDir } from "../storage/draft-store.js";
import type { ServiceDraft } from "../wizard/types.js";
function makeDraft(id = "d-1"): ServiceDraft {
const now = new Date().toISOString();
return {
id,
name: "Demo",
slug: "demo",
description: "",
baseUrl: "https://example.com",
transport: "http",
endpoints: [{ id: "ep-1", name: "Ping", method: "GET", path: "/ping", params: "id" }],
credential: { kind: "none" },
createdAt: now,
updatedAt: now,
};
}
describe("generateCli", () => {
it("creates an executable file in artifact dir", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "clipp-gen-"));
const draft = makeDraft();
const artifact = await generateCli({ draft, outDir: getArtifactDir(draft.id, rootDir) });
await access(artifact.binPath);
const contents = await readFile(artifact.binPath, "utf8");
expect(contents).toContain("const draft =");
if (process.platform !== "win32") {
const mode = (await stat(artifact.binPath)).mode & 0o777;
expect(mode).toBe(0o755);
}
});
it("supports persisting generatedAt on rerun", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "clipp-gen-store-"));
const store = createDraftStore({ rootDir });
const created = await store.create(makeDraft("d-2"));
const first = await generateCli({ draft: created, outDir: getArtifactDir(created.id, rootDir) });
const updated = await store.update(created.id, { generatedAt: first.generatedAt, artifactPath: first.binPath });
const second = await generateCli({ draft: updated, outDir: getArtifactDir(updated.id, rootDir) });
const updatedAgain = await store.update(created.id, { generatedAt: second.generatedAt, artifactPath: second.binPath });
expect(updatedAgain.generatedAt).toBe(second.generatedAt);
expect(updatedAgain.generatedAt).not.toBe(first.generatedAt);
});
});

View File

@@ -10,6 +10,9 @@ vi.mock("lucide-react", () => ({
Pencil: () => null,
RefreshCw: () => null,
Trash2: () => null,
Play: () => null,
CheckCircle2: () => null,
AlertTriangle: () => null,
}));
function makeDraft(id: string, name = "Demo"): ServiceDraft {
@@ -58,7 +61,8 @@ describe("CliPrintingPressManageView", () => {
if (method === "GET" && url.endsWith(`/drafts/${draft2.id}`)) return { ok: true, json: async () => draft2 };
if (method === "PUT" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, json: async () => updated };
if (method === "POST" && url.endsWith(`/drafts/${draft1.id}/regenerate`)) {
return { ok: true, json: async () => ({ draft: { ...updated, regeneratedAt: new Date().toISOString() }, stub: true, message: "Regenerate stub — full generation lands in FN-3765/FN-3767" }) };
const generatedAt = new Date().toISOString();
return { ok: true, json: async () => ({ draft: { ...updated, regeneratedAt: generatedAt, generatedAt, artifactPath: "/tmp/demo.mjs" }, artifact: { draftId: draft1.id, slug: draft1.slug, binPath: "/tmp/demo.mjs", entrypoint: "node", generatedAt } }) };
}
if (method === "DELETE" && url.endsWith(`/drafts/${draft1.id}`)) return { ok: true, status: 204, json: async () => ({}) };
return { ok: false, status: 404, json: async () => ({ error: "Not found" }) };
@@ -85,8 +89,8 @@ describe("CliPrintingPressManageView", () => {
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "PUT" })));
await user.click(screen.getByRole("button", { name: /Regenerate/i }));
expect(await screen.findByText(/Regenerate stub/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: /^Regenerate$/i }));
expect(await screen.findByText(/Regenerated at/i)).toBeTruthy();
await user.click(screen.getByRole("button", { name: /Delete/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(`/drafts/${draft1.id}`), expect.objectContaining({ method: "DELETE" })));

View File

@@ -0,0 +1,93 @@
import { mkdtemp } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createCliPrintingPressRoutes } from "../routes/wizard-routes.js";
import type { ServiceDraft } from "../wizard/types.js";
function makeDraft(baseUrl: string): ServiceDraft {
const now = new Date().toISOString();
return {
id: "",
name: "Demo",
slug: "demo",
description: "",
baseUrl,
transport: "http",
endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping" }],
credential: { kind: "none" },
createdAt: now,
updatedAt: now,
};
}
function route(method: string, path: string) {
const found = createCliPrintingPressRoutes().find((entry) => entry.method === method && entry.path === path);
if (!found) throw new Error(`missing route ${method} ${path}`);
return found;
}
const servers: Array<{ close: () => void }> = [];
afterEach(() => {
while (servers.length) servers.pop()?.close();
});
async function startServer(handler: (req: any, res: any) => void): Promise<string> {
const server = createServer(handler);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
servers.push(server);
const addr = server.address();
if (!addr || typeof addr === "string") throw new Error("Invalid address");
return `http://127.0.0.1:${addr.port}`;
}
describe("run routes", () => {
it("regenerates and runs successfully", async () => {
const baseUrl = await startServer((_req, res) => {
res.statusCode = 200;
res.end("pong");
});
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
const id = (createRes.body as { id: string }).id;
const regenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
expect(regenRes.status).toBe(200);
expect((regenRes.body as any).stub).toBeUndefined();
const runRes = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {} } }, ctx);
expect(runRes.status).toBe(200);
expect((runRes.body as any).stdout).toContain("pong");
});
it("returns validation, 404, 409, and timeout responses", async () => {
const baseUrl = await startServer((_req, res) => {
setTimeout(() => {
res.statusCode = 200;
res.end("slow");
}, 50);
});
const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-run-routes-"));
const ctx = { taskStore: { getRootDir: () => rootDir } } as any;
const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft(baseUrl) }, ctx);
const id = (createRes.body as { id: string }).id;
const noArtifact = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {} } }, ctx);
expect(noArtifact.status).toBe(409);
const badBody = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "", params: {} } }, ctx);
expect(badBody.status).toBe(400);
const unknown = await route("POST", "/drafts/:id/run").handler({ params: { id: "missing" }, body: { endpointId: "e1", params: {} } }, ctx);
expect(unknown.status).toBe(404);
await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
const timeout = await route("POST", "/drafts/:id/run").handler({ params: { id }, body: { endpointId: "e1", params: {}, timeoutMs: 1 } }, ctx);
expect(timeout.status).toBe(200);
expect((timeout.body as any).timedOut).toBe(true);
});
});

View File

@@ -0,0 +1,55 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runGeneratedCli } from "../generation/runner.js";
import type { GeneratedCliArtifact } from "../generation/types.js";
async function writeFixture(script: string): Promise<{ artifact: GeneratedCliArtifact; root: string }> {
const root = await mkdtemp(join(tmpdir(), "clipp-runner-"));
const binPath = join(root, "fixture.mjs");
await writeFile(binPath, script, "utf8");
return {
root,
artifact: {
draftId: "draft-1",
slug: "fixture",
binPath,
entrypoint: "node",
generatedAt: new Date().toISOString(),
},
};
}
describe("runGeneratedCli", () => {
it("returns success output", async () => {
const { artifact, root } = await writeFixture("console.log('ok-output')");
const result = await runGeneratedCli({ artifact, endpointId: "x", params: { enabled: true }, cwd: root });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("ok-output");
expect(result.timedOut).toBe(false);
});
it("captures non-zero exit", async () => {
const { artifact, root } = await writeFixture("console.error('bad-output'); process.exit(7)");
const result = await runGeneratedCli({ artifact, endpointId: "x", params: {}, cwd: root });
expect(result.exitCode).toBe(7);
expect(result.stderr).toContain("bad-output");
});
it("captures timeout", async () => {
const { artifact, root } = await writeFixture("await new Promise((resolve) => setTimeout(resolve, 500)); console.log('late')");
const result = await runGeneratedCli({ artifact, endpointId: "x", params: {}, timeoutMs: 1, cwd: root });
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBeNull();
});
it("redacts credentials from stdout and argv echo", async () => {
const secret = "super-secret-value";
const { artifact, root } = await writeFixture("console.log(process.env.CLIPP_CRED_API_KEY)");
const result = await runGeneratedCli({ artifact, endpointId: "x", params: { token: secret }, credentials: { api_key: secret }, cwd: root });
expect(result.stdout).not.toContain(secret);
expect(result.argv.join(" ")).not.toContain(secret);
expect(result.stdout).toContain("***");
});
});

View File

@@ -47,7 +47,7 @@ describe("wizard routes", () => {
const regenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id } }, ctx);
expect(regenRes.status).toBe(200);
expect((regenRes.body as { stub: boolean }).stub).toBe(true);
expect((regenRes.body as { artifact?: { binPath: string } }).artifact?.binPath).toBeTruthy();
const missingRegenRes = await route("POST", "/drafts/:id/regenerate").handler({ params: { id: "missing" } }, ctx);
expect(missingRegenRes.status).toBe(404);