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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" })));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("***");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user