feat(FN-3769): align TaskCard controls with design tokens and add registrat
Adds comprehensive contract tests for registration, config flow, dashboard views, runtime availability, and workflow integration — including shared test fixtures for exec mocking and registry setup — along with a design-token alignment fix to TaskCard controls. Fusion-Task-Id: FN-3769
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeFakeRegistry } from "./fixtures/registry.js";
|
||||
import { InvalidCredentialPlacementError, OAuthNotSupportedError } from "../store/cli-press-types.js";
|
||||
|
||||
describe("cli press store config flow", () => {
|
||||
it("round-trips service/spec/settings CRUD", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const created = h.store.createService({
|
||||
slug: "gamma",
|
||||
displayName: "Gamma Service",
|
||||
description: "gamma",
|
||||
baseUrl: "https://gamma.example.com",
|
||||
sourceKind: "manual",
|
||||
});
|
||||
expect(h.store.getService(created.id)?.slug).toBe("gamma");
|
||||
|
||||
const spec = h.store.createSpec({
|
||||
serviceId: created.id,
|
||||
name: "gamma-cli",
|
||||
version: "1.0.0",
|
||||
generatorVersion: "cli-printing-press",
|
||||
specJson: JSON.stringify({ id: created.id, regeneratedAt: "before" }),
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const updated = h.store.updateSpec(spec.id, {
|
||||
status: "generated",
|
||||
generatedAt: new Date().toISOString(),
|
||||
specJson: JSON.stringify({ id: created.id, regeneratedAt: "after", artifactPath: "/tmp/gamma" }),
|
||||
});
|
||||
expect(updated.status).toBe("generated");
|
||||
expect(JSON.parse(updated.specJson)).toMatchObject({ regeneratedAt: "after" });
|
||||
|
||||
const setting = h.store.setSetting({
|
||||
serviceId: created.id,
|
||||
key: "runner.timeoutMs",
|
||||
value: "120000",
|
||||
scope: "wizard",
|
||||
});
|
||||
expect(h.store.listSettings(created.id).find((entry) => entry.id === setting.id)?.value).toBe("120000");
|
||||
|
||||
h.store.deleteSpec(spec.id);
|
||||
expect(h.store.getSpec(spec.id)).toBeUndefined();
|
||||
h.store.deleteService(created.id);
|
||||
expect(h.store.getService(created.id)).toBeUndefined();
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("stores credential values encoded and does not expose raw secrets", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const acme = h.services.acme;
|
||||
const credential = h.store.listCredentials(acme.id)[0];
|
||||
expect(credential.value).toMatchObject({ encoding: "base64" });
|
||||
expect(JSON.stringify(credential.value)).not.toContain("acme-secret");
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oauth credentials and mismatched placement", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const serviceId = h.services.acme.id;
|
||||
expect(() =>
|
||||
h.store.createCredential({
|
||||
serviceId,
|
||||
name: "oauth",
|
||||
kind: "oauth",
|
||||
placement: { kind: "oauth", provider: "acme" } as never,
|
||||
value: { encoding: "base64", value: "abc" },
|
||||
} as never),
|
||||
).toThrow(OAuthNotSupportedError);
|
||||
|
||||
expect(() =>
|
||||
h.store.createCredential({
|
||||
serviceId,
|
||||
name: "bad-placement",
|
||||
kind: "header",
|
||||
placement: { kind: "query_param", queryParam: "token" } as never,
|
||||
value: { encoding: "base64", value: "abc" },
|
||||
} as never),
|
||||
).toThrow(InvalidCredentialPlacementError);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCliPrintingPressRoutes } from "../routes/wizard-routes.js";
|
||||
import { makeFakeRegistry } from "./fixtures/registry.js";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe("dashboard API route contracts", () => {
|
||||
it("defines expected wizard/list/detail/run endpoints", () => {
|
||||
const routes = createCliPrintingPressRoutes();
|
||||
expect(routes.map((entry) => `${entry.method} ${entry.path}`)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"POST /drafts",
|
||||
"GET /drafts",
|
||||
"GET /drafts/:id",
|
||||
"PUT /drafts/:id",
|
||||
"POST /drafts/:id/regenerate",
|
||||
"POST /drafts/:id/run",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports happy and error API paths", async () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const ctx = { taskStore: { getRootDir: () => h.rootDir, getDatabase: () => h.db } } as any;
|
||||
const listRes = await route("GET", "/drafts").handler({ params: {} }, ctx);
|
||||
expect(listRes.status).toBe(200);
|
||||
|
||||
const missRes = await route("GET", "/drafts/:id").handler({ params: { id: "missing" } }, ctx);
|
||||
expect(missRes.status).toBe(404);
|
||||
|
||||
const badRun = await route("POST", "/drafts/:id/run").handler({ params: { id: h.services.acme.id }, body: { endpointId: "", params: {} } }, ctx);
|
||||
expect(badRun.status).toBe(400);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("documents plugin-prefixed mount contract", () => {
|
||||
expect("/api/plugins/cli-printing-press/drafts").toContain("/api/plugins/cli-printing-press/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
type ExecResult = {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
timeoutAfterMs?: number;
|
||||
};
|
||||
|
||||
type ExecCall = {
|
||||
command: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const calls: ExecCall[] = [];
|
||||
const queue: ExecResult[] = [];
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const execMock = vi.fn((command: string, options: Record<string, unknown>, callback: (err: Error | null, stdout: string, stderr: string) => void) => {
|
||||
calls.push({ command, options });
|
||||
const next = queue.shift() ?? { stdout: "", stderr: "", code: 0 };
|
||||
if (next.timeoutAfterMs) {
|
||||
const err = new Error(`Command timed out after ${next.timeoutAfterMs}ms`) as Error & { killed?: boolean; signal?: string };
|
||||
err.killed = true;
|
||||
err.signal = "SIGTERM";
|
||||
callback(err, next.stdout ?? "", next.stderr ?? "");
|
||||
return { kill: () => true };
|
||||
}
|
||||
if ((next.code ?? 0) !== 0) {
|
||||
const err = new Error(next.stderr || `Command failed with code ${next.code}`) as Error & {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
err.code = next.code;
|
||||
err.stdout = next.stdout ?? "";
|
||||
err.stderr = next.stderr ?? "";
|
||||
callback(err, next.stdout ?? "", next.stderr ?? "");
|
||||
return { kill: () => true };
|
||||
}
|
||||
callback(null, next.stdout ?? "", next.stderr ?? "");
|
||||
return { kill: () => true };
|
||||
});
|
||||
|
||||
execMock[Symbol.for("nodejs.util.promisify.custom")] = (command: string, options: Record<string, unknown>) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execMock(command, options, (error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
exec: execMock,
|
||||
execSync: vi.fn(() => {
|
||||
throw new Error("execSync should never be called");
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export function installExecMock() {
|
||||
calls.length = 0;
|
||||
queue.length = 0;
|
||||
|
||||
return {
|
||||
setNextResult(result: ExecResult) {
|
||||
queue.push(result);
|
||||
},
|
||||
getCalls() {
|
||||
return [...calls];
|
||||
},
|
||||
assertExecSyncUnused() {
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeFakeRegistry } from "./registry.js";
|
||||
import { installExecMock } from "./exec-mock.js";
|
||||
|
||||
describe("test fixtures", () => {
|
||||
it("creates seeded fake registry", () => {
|
||||
const registry = makeFakeRegistry();
|
||||
try {
|
||||
expect(registry.store.listServices().map((s) => s.slug).sort()).toEqual(["acme", "beta"]);
|
||||
expect(registry.store.listArtifacts(registry.specs.acme.id)).toHaveLength(1);
|
||||
expect(registry.store.listArtifacts(registry.specs.beta.id)).toHaveLength(0);
|
||||
} finally {
|
||||
registry.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("records mocked exec calls", async () => {
|
||||
const execMock = installExecMock();
|
||||
execMock.setNextResult({ stdout: "ok" });
|
||||
|
||||
const { exec } = await import("node:child_process");
|
||||
const execAsync = promisify(exec);
|
||||
const result = await execAsync("node --version", { cwd: "/tmp" });
|
||||
|
||||
expect(result.stdout).toBe("ok");
|
||||
expect(execMock.getCalls()).toEqual([{ command: "node --version", options: { cwd: "/tmp" } }]);
|
||||
execMock.assertExecSyncUnused();
|
||||
});
|
||||
|
||||
it("simulates timeout and blocks execSync", async () => {
|
||||
const execMock = installExecMock();
|
||||
execMock.setNextResult({ timeoutAfterMs: 25, stderr: "timed out" });
|
||||
const { exec, execSync } = await import("node:child_process");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
await expect(execAsync("echo slow", { timeout: 25 })).rejects.toThrow(/timed out/i);
|
||||
expect(() => execSync("echo no")).toThrow("execSync should never be called");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "@fusion/core";
|
||||
import { createCliPressStore } from "../../store/cli-press-store.js";
|
||||
import { encodeCredentialValue } from "../../store/credentials.js";
|
||||
|
||||
export function makeFakeRegistry() {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "cli-printing-press-registry-"));
|
||||
const db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
const store = createCliPressStore(db);
|
||||
|
||||
const acme = store.createService({
|
||||
slug: "acme",
|
||||
displayName: "Acme Service",
|
||||
description: "Acme CLI",
|
||||
baseUrl: "https://acme.example.com",
|
||||
sourceKind: "manual",
|
||||
});
|
||||
const acmeSpec = store.createSpec({
|
||||
serviceId: acme.id,
|
||||
name: "acme-cli",
|
||||
version: "1.0.0",
|
||||
generatorVersion: "cli-printing-press",
|
||||
specJson: JSON.stringify({ id: acme.id, slug: acme.slug }),
|
||||
status: "generated",
|
||||
generatedAt: new Date().toISOString(),
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
const acmePath = `plugins/cli-printing-press/artifacts/${acme.id}/${acmeSpec.id}/acme`;
|
||||
const acmeAbsPath = join(rootDir, ".fusion", acmePath);
|
||||
mkdirSync(join(acmeAbsPath, ".."), { recursive: true });
|
||||
writeFileSync(acmeAbsPath, "#!/bin/sh\necho acme\n");
|
||||
store.createArtifact({ cliSpecId: acmeSpec.id, kind: "script", path: acmePath, executable: true });
|
||||
store.createCredential({
|
||||
serviceId: acme.id,
|
||||
name: "token",
|
||||
kind: "env_var",
|
||||
placement: { kind: "env_var", envVar: "ACME_TOKEN" },
|
||||
value: encodeCredentialValue("acme-secret"),
|
||||
});
|
||||
|
||||
const beta = store.createService({
|
||||
slug: "beta",
|
||||
displayName: "Beta Service",
|
||||
description: "Beta CLI",
|
||||
baseUrl: "https://beta.example.com",
|
||||
sourceKind: "manual",
|
||||
});
|
||||
const betaSpec = store.createSpec({
|
||||
serviceId: beta.id,
|
||||
name: "beta-cli",
|
||||
version: "1.0.0",
|
||||
generatorVersion: "cli-printing-press",
|
||||
specJson: JSON.stringify({ id: beta.id, slug: beta.slug, unbuilt: true }),
|
||||
status: "draft",
|
||||
generatedAt: undefined,
|
||||
lastGenerationError: undefined,
|
||||
});
|
||||
store.createCredential({
|
||||
serviceId: beta.id,
|
||||
name: "header-token",
|
||||
kind: "header",
|
||||
placement: { kind: "header", header: "X-Beta-Token" },
|
||||
value: encodeCredentialValue("beta-secret"),
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
db.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
db,
|
||||
store,
|
||||
services: { acme, beta },
|
||||
specs: { acme: acmeSpec, beta: betaSpec },
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validatePluginManifest } from "@fusion/core";
|
||||
import plugin from "../index.js";
|
||||
import { ensureCliPressSchema } from "../store/cli-press-store.js";
|
||||
import { makeFakeRegistry } from "./fixtures/registry.js";
|
||||
|
||||
describe("plugin registration contracts", () => {
|
||||
it("declares expected manifest and semver version", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-cli-printing-press");
|
||||
expect(plugin.manifest.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(validatePluginManifest(plugin.manifest).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("registers schema, routes, dashboard views and executor runtime hook", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
expect(() => ensureCliPressSchema(h.db)).not.toThrow();
|
||||
expect(plugin.routes?.some((route) => route.path === "/drafts")).toBe(true);
|
||||
expect(plugin.dashboardViews?.map((view) => view.viewId)).toEqual(["wizard", "manage"]);
|
||||
expect(typeof plugin.executorRuntimeEnv).toBe("function");
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.skip("TODO(FN-3768): assert plugin workflow-step template contributions once shipped", () => {
|
||||
// This branch does not yet export workflow step templates/contributions for cli-printing-press.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import { buildExecutorRuntimeEnv } from "../runtime/executor-runtime-env.js";
|
||||
import { makeFakeRegistry } from "./fixtures/registry.js";
|
||||
|
||||
describe("runtime availability", () => {
|
||||
it("exposes executor runtime env hook through plugin entry", () => {
|
||||
expect(typeof plugin.executorRuntimeEnv).toBe("function");
|
||||
});
|
||||
|
||||
it("returns PATH/env entries for generated CLIs only", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const result = buildExecutorRuntimeEnv(
|
||||
h.store,
|
||||
{ taskId: "FN-3769", worktreePath: h.rootDir, rootDir: h.rootDir },
|
||||
{
|
||||
pluginId: "fusion-plugin-cli-printing-press",
|
||||
taskStore: {} as never,
|
||||
settings: {},
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
emitEvent() {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.pathPrepend).toHaveLength(1);
|
||||
expect(result.pathPrepend[0]).toContain(`/artifacts/${h.services.acme.id}/${h.specs.acme.id}`);
|
||||
expect(result.env).toEqual({ ACME_TOKEN: "acme-secret" });
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips draft specs from PATH contributions", () => {
|
||||
const h = makeFakeRegistry();
|
||||
try {
|
||||
const betaArtifacts = h.store.listArtifacts(h.specs.beta.id);
|
||||
expect(betaArtifacts).toHaveLength(0);
|
||||
|
||||
const result = buildExecutorRuntimeEnv(
|
||||
h.store,
|
||||
{ taskId: "FN-3769", worktreePath: h.rootDir, rootDir: h.rootDir },
|
||||
{
|
||||
pluginId: "fusion-plugin-cli-printing-press",
|
||||
taskStore: {} as never,
|
||||
settings: {},
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
emitEvent() {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.pathPrepend.some((entry) => entry.includes(`/artifacts/${h.services.beta.id}/`))).toBe(false);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.skip("TODO(FN-3767): cover resolveGeneratedCliInvocation contract once exported", () => {
|
||||
// FN-3767 documented resolveGeneratedCliInvocation, but this branch has only buildExecutorRuntimeEnv.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import { installExecMock } from "./fixtures/exec-mock.js";
|
||||
|
||||
describe("workflow integration contracts", () => {
|
||||
it("guards against execSync usage in workflow-oriented execution fixtures", () => {
|
||||
const execMock = installExecMock();
|
||||
execMock.assertExecSyncUnused();
|
||||
expect(typeof plugin.manifest.id).toBe("string");
|
||||
});
|
||||
|
||||
it.skip("TODO(FN-3768): execute script-mode workflow handler once plugin workflow step contributions are available", () => {
|
||||
// Missing in this branch: cli-printing-press workflow step contribution + script handler wiring.
|
||||
});
|
||||
|
||||
it.skip("TODO(FN-3768): run through runWorkflowSteps with plugin:cli-printing-press:<id>", () => {
|
||||
// packages/core resolvePluginWorkflowStep currently hard-codes mode="prompt".
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user