/** * Tests for the CLI-facing component helpers (#560, epic #551): `listComponents` * (backs `chant list --components`), `describeComponent` (backs * `chant describe --components`), `computeComponentGraph` (backs * `chant graph --components`), and `runComponents` (backs `chant run * --components `, #585). Mirrors `../cli/commands/list.test.ts`'s * style of exercising the pure command function directly against a temp dir, * rather than going through the CLI arg-parsing/handler dispatch layer. */ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join, dirname, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import Ajv2020 from "ajv/dist/2020"; import componentSchema from "./component.schema.json"; import { listComponents, describeComponent, computeComponentGraph, runComponents, findComponentGate, resolveComponentTargets, generateComponentsPipeline, } from "./cli-support"; import { CapabilityRegistry, type DeployContext } from "./capability"; import type { DriverComponent, RunProgressEvent } from "./driver"; // A minimal stand-in for the real gitlab lexicon plugin, satisfying // `isLexiconPlugin` (../lexicon.ts) — used only by the `generateComponentsPipeline` // buildParams test below, so that test doesn't depend on how `@intentius/ // chant-lexicon-gitlab`'s own real module resolves under the test runner. vi.mock("@intentius/chant-lexicon-gitlab", () => ({ gitlab: { name: "gitlab", serializer: { name: "gitlab", rulePrefix: "GL", serialize: () => "" }, generate: () => {}, validate: () => [], coverage: () => ({ total: 0, covered: 0 }), package: () => "gitlab", generateComponentPipeline: (components: DriverComponent[]) => ({ yaml: components.map((c) => c.name).join(","), stages: ["wave-1"], jobs: components.map((c) => ({ jobName: c.name, component: c.name, stage: "wave-1", needs: [] })), }), }, })); describe("listComponents", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-list-components-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("returns an empty, successful result for a directory with no components", async () => { const result = await listComponents(testDir); expect(result.success).toBe(true); expect(result.components).toEqual([]); }); test("lists a discovered component with archetype inferred and phases in order", async () => { await writeFile( join(testDir, "orders.component.ts"), ` export const ordersTable = { name: "orders-table", dependsOn: [], deploy: [ { phase: "Apply", steps: [{ kind: "cfn-deploy" }] }, { phase: "Verify", steps: [{ kind: "wait-for-stack" }] }, ], }; `, ); const result = await listComponents(testDir); expect(result.success).toBe(true); expect(result.components).toHaveLength(1); expect(result.components[0]).toMatchObject({ name: "orders-table", archetype: "infra", dependsOn: [], hasBuild: false, phases: ["Apply", "Verify"], }); expect(result.components[0]!.filePath).toMatch(/orders\.component\.ts$/); }); test("sorts components by name", async () => { await writeFile( join(testDir, "z.component.ts"), `export const zeta = { name: "zeta", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); await writeFile( join(testDir, "a.component.ts"), `export const alpha = { name: "alpha", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); const result = await listComponents(testDir); expect(result.components.map((c) => c.name)).toEqual(["alpha", "zeta"]); }); test("reports hasBuild: true and the service archetype for a build + apply component", async () => { await writeFile( join(testDir, "svc.component.ts"), ` export const svc = { name: "svc", dependsOn: [], build: { kind: "docker-build" }, deploy: [ { phase: "Publish", steps: [{ kind: "publish-image" }] }, { phase: "Apply", steps: [{ kind: "ecs-update-service" }] }, ], }; `, ); const result = await listComponents(testDir); expect(result.components[0]).toMatchObject({ hasBuild: true, archetype: "service" }); }); test("fails with discovery errors surfaced when a component file has a syntax error", async () => { await writeFile(join(testDir, "bad.component.ts"), `export const bad = { name: "bad"`); const result = await listComponents(testDir); expect(result.success).toBe(false); expect(result.errors.length).toBeGreaterThan(0); }); }); describe("describeComponent", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-describe-components-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("describes a component by its schema name, not its export name", async () => { await writeFile( join(testDir, "svc.component.ts"), ` export const exportedAsThis = { name: "search-service", dependsOn: ["shared-alb"], build: { kind: "docker-build" }, deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy" }] }], }; `, ); const result = await describeComponent(testDir, "search-service"); expect(result.success).toBe(true); expect(result.described?.name).toBe("search-service"); expect(result.described?.filePath).toMatch(/svc\.component\.ts$/); expect(result.described?.json).toMatchObject({ name: "search-service", dependsOn: ["shared-alb"], archetype: "service", }); }); test("fails with a helpful message (listing known components) when the name is not found", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "known-one", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); const result = await describeComponent(testDir, "nonexistent"); expect(result.success).toBe(false); expect(result.described).toBeUndefined(); expect(result.output).toContain("nonexistent"); expect(result.output).toContain("known-one"); }); test("the JSON projection validates against component.schema.json", async () => { await writeFile( join(testDir, "svc.component.ts"), ` export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "t.json" }] }], }; `, ); const result = await describeComponent(testDir, "svc"); expect(result.success).toBe(true); const ajv = new Ajv2020({ strict: true, allErrors: true }); const validate = ajv.compile(componentSchema); expect(validate(result.described!.json)).toBe(true); }); }); describe("computeComponentGraph", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-graph-components-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("returns empty order/waves/edges for no components", async () => { const result = await computeComponentGraph(testDir); expect(result.success).toBe(true); expect(result.order).toEqual([]); expect(result.waves).toEqual([]); expect(result.edges).toEqual([]); }); // #1490 — `discoverComponents` has honoured buildParams since #1108, but // every caller stopped short of passing them, so the component graph was // always the default-parameter graph. A component conditioned on a parameter // survived `--param` that removed the resources it describes, and `chant // build` and `chant graph --components` disagreed about the same source. // // The fixture imports the params module by absolute path rather than by the // `@intentius/chant/params` specifier: it is written to a temp dir with no // node_modules, so the bare specifier would resolve elsewhere (or nowhere) // and the fixture would read a DIFFERENT params object than the one // discovery mutates — passing the test for the wrong reason. const paramsModule = new URL("../params.ts", import.meta.url).pathname; const conditionalComponent = `import { params } from ${JSON.stringify(paramsModule)}; export const backup = params.backups === "omit" ? undefined : { name: "backup", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`; // A fresh directory per call: the ESM loader caches a module by path, so // re-importing the same fixture would replay the FIRST call's parameter // binding and the assertion would pass or fail for the wrong reason. async function graphWith( dir: string, provenance?: Array<{ name: string; value: string; source: "cli" }>, ) { await mkdir(dir, { recursive: true }); await writeFile(join(dir, "backup.component.ts"), conditionalComponent); return computeComponentGraph(dir, undefined, provenance); } test("a component conditioned on a build parameter follows the parameter", async () => { const omitted = await graphWith(join(testDir, "omit"), [ { name: "backups", value: "omit", source: "cli" }, ]); expect(omitted.success).toBe(true); expect(omitted.order).toEqual([]); const kept = await graphWith(join(testDir, "keep"), [ { name: "backups", value: "pg-dump", source: "cli" }, ]); expect(kept.success).toBe(true); expect(kept.order).toEqual(["backup"]); }); // Supplying none must not inherit whatever a previous call set — the same // leak `discoverComponents`'s unconditional setBuildParams guards against. test("passing no parameters does not inherit the previous call's", async () => { await graphWith(join(testDir, "first"), [{ name: "backups", value: "omit", source: "cli" }]); const fresh = await graphWith(join(testDir, "second")); expect(fresh.order).toEqual(["backup"]); }); test("orders a consumer after its producer, with a consumer → producer edge", async () => { await writeFile( join(testDir, "alb.component.ts"), `export const alb = { name: "shared-alb", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy" }] }] };`, ); await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "search-service", dependsOn: ["shared-alb"], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy" }] }] };`, ); const result = await computeComponentGraph(testDir); expect(result.success).toBe(true); expect(result.order.indexOf("shared-alb")).toBeLessThan(result.order.indexOf("search-service")); expect(result.edges).toContainEqual({ from: "search-service", to: "shared-alb" }); // Independent components share a wave; a dependent is in a strictly later wave. const albWave = result.waves.findIndex((w) => w.includes("shared-alb")); const svcWave = result.waves.findIndex((w) => w.includes("search-service")); expect(svcWave).toBeGreaterThan(albWave); }); test("fails with a clear error on a dependency cycle", async () => { await writeFile( join(testDir, "a.component.ts"), `export const a = { name: "a", dependsOn: ["b"], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); await writeFile( join(testDir, "b.component.ts"), `export const b = { name: "b", dependsOn: ["a"], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); const result = await computeComponentGraph(testDir); expect(result.success).toBe(false); expect(result.error).toMatch(/cycle/i); }); test("fails with a clear error when dependsOn names an undiscovered component", async () => { await writeFile( join(testDir, "a.component.ts"), `export const a = { name: "a", dependsOn: ["ghost"], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`, ); const result = await computeComponentGraph(testDir); expect(result.success).toBe(false); expect(result.error).toMatch(/unknown component|ghost/i); }); test("enabled: false sits out of an \"all\" run, and its name leaves the survivors' dependsOn (#1522)", async () => { await writeFile( join(testDir, "gated.component.ts"), `export const gated = { name: "gated", enabled: false, dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`, ); await writeFile( join(testDir, "main.component.ts"), `export const main = { name: "main", dependsOn: ["gated"], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`, ); const resolved = await resolveComponentTargets(testDir, "all"); expect(resolved.success).toBe(true); expect(resolved.targets.map((t) => t.name)).toEqual(["main"]); expect(resolved.targets[0].dependsOn).toEqual([]); }); test("running a disabled component BY NAME errors with the reason (#1522)", async () => { await writeFile( join(testDir, "gated.component.ts"), `export const gated = { name: "gated", enabled: false, dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`, ); const resolved = await resolveComponentTargets(testDir, "gated"); expect(resolved.success).toBe(false); expect(resolved.error).toMatch(/disabled under this run's parameters/); }); test("carries each component's liveNames, with the [name] identity fallback (#1491)", async () => { await writeFile( join(testDir, "pg.component.ts"), `export const pg = { name: "postgres", liveNames: ["pgDeployment", "pgService", "pgClaim"], dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`, ); await writeFile( join(testDir, "plain.component.ts"), `export const plain = { name: "plain", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`, ); const result = await computeComponentGraph(testDir); expect(result.success).toBe(true); expect(result.liveNames?.postgres).toEqual(["pgDeployment", "pgService", "pgClaim"]); expect(result.liveNames?.plain).toEqual(["plain"]); }); }); // ── runComponents (#585) ───────────────────────────────────────────────────── /** A fake capability that records every call and returns a canned output — mirrors ../driver.test.ts's `fakeCapability`. */ function fakeCapability(kind: string, opts?: { failRun?: boolean; output?: unknown }) { const calls: { ctx: DeployContext; input: unknown }[] = []; return { kind, async run(ctx: DeployContext, input: unknown) { calls.push({ ctx, input }); if (opts?.failRun) throw new Error(`${kind} failed`); return opts?.output ?? { ok: true }; }, calls, }; } /** A registry with one fake "shell"-like capability, `deploy-thing`, standing in for a real verb so tests don't hit the still-stubbed starter set. */ function fakeRegistry(opts?: { failRun?: boolean }): CapabilityRegistry { const registry = new CapabilityRegistry(); registry.register(fakeCapability("deploy-thing", opts)); return registry; } describe("findComponentGate", () => { test("returns undefined for a component with no gate", () => { const component: DriverComponent = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }], }; expect(findComponentGate(component)).toBeUndefined(); }); test("finds a gate in a top-level deploy phase", () => { const component: DriverComponent = { name: "svc", dependsOn: [], deploy: [{ phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }], }; expect(findComponentGate(component)).toEqual({ kind: "gate", signalName: "release-approval" }); }); test("finds a gate nested inside a fan-out phase", () => { const component: DriverComponent = { name: "fleet", dependsOn: [], deploy: [ { phase: "Rollout", steps: [ { phase: "instance-2", steps: [{ kind: "gate", signalName: "instance-2-approval" }], }, ], }, ], }; expect(findComponentGate(component)?.signalName).toBe("instance-2-approval"); }); test("finds a gate in a component's rollback phases", () => { const component: DriverComponent = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }], rollback: [{ phase: "Rollback", steps: [{ kind: "gate", signalName: "rollback-approval" }] }], }; expect(findComponentGate(component)?.signalName).toBe("rollback-approval"); }); }); describe("runComponents", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-run-components-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("runs a single named component through the driver", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "svc", { env: "staging", registry }); expect(result.success).toBe(true); expect(result.selected).toEqual(["svc"]); expect(result.run?.ok).toBe(true); expect(result.run?.results).toHaveLength(1); expect(result.run?.results[0]).toMatchObject({ component: "svc", ok: true }); expect(result.run?.results[0].records[0]).toMatchObject({ component: "svc", kind: "deploy-thing", status: "ok" }); }); test("threads --env into DeployContext", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); await runComponents(testDir, "svc", { env: "prod", registry }); expect(capability.calls).toHaveLength(1); expect(capability.calls[0].ctx.env).toBe("prod"); expect(capability.calls[0].ctx.component).toBe("svc"); }); test("defaults --env to \"local\" when omitted", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); await runComponents(testDir, "svc", { registry }); expect(capability.calls[0].ctx.env).toBe("local"); }); test("runs a component whose dependsOn names infra outside the discovered set (no UnknownDependencyError)", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "search-service", dependsOn: ["shared-alb"], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "search-service", { registry }); expect(result.success).toBe(true); expect(result.run?.ok).toBe(true); }); test("all: dispatches every discovered component in wave order", async () => { await writeFile( join(testDir, "alb.component.ts"), `export const alb = { name: "shared-alb", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "search-service", dependsOn: ["shared-alb"], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "all", { registry }); expect(result.success).toBe(true); expect(result.selected.sort()).toEqual(["search-service", "shared-alb"]); expect(result.run?.waves).toEqual([["shared-alb"], ["search-service"]]); expect(result.run?.order).toEqual(["shared-alb", "search-service"]); expect(result.run?.results.every((r) => r.ok)).toBe(true); }); test("all: fails clearly on a dependency cycle (no exception thrown)", async () => { await writeFile( join(testDir, "a.component.ts"), `export const a = { name: "a", dependsOn: ["b"], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); await writeFile( join(testDir, "b.component.ts"), `export const b = { name: "b", dependsOn: ["a"], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "all", { registry }); expect(result.success).toBe(false); expect(result.error).toMatch(/cycle/i); }); test("unknown component name → clear error listing known components", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "missing", { registry }); expect(result.success).toBe(false); expect(result.error).toContain('Component "missing" not found'); expect(result.error).toContain("svc"); }); test("a gate in the selected component is rejected before any step runs", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [ { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }, { phase: "Apply", steps: [{ kind: "deploy-thing" }] }, ] };`, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); const result = await runComponents(testDir, "svc", { registry }); expect(result.success).toBe(false); expect(result.gateUnsupported).toEqual({ component: "svc", signalName: "release-approval" }); // Pre-flighted: the deploy-thing step after the gate never ran. expect(capability.calls).toHaveLength(0); }); test("a failing step surfaces a failed component result, not a thrown exception", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry({ failRun: true }); const result = await runComponents(testDir, "svc", { registry }); expect(result.success).toBe(false); expect(result.run?.ok).toBe(false); expect(result.run?.failedComponent).toBe("svc"); expect(result.run?.results[0].records[0]).toMatchObject({ status: "fail" }); }); test("discovery error surfaces as a failure, not an exception", async () => { await writeFile(join(testDir, "broken.component.ts"), `export const broken = notDefined;`); const result = await runComponents(testDir, "all"); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); // ── config-defaults wiring (#629) ────────────────────────────────────────── test("threads chant.config.ts's signing defaults into a verify step's policy before dispatch", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Verify", steps: [{ kind: "verify", imageRef: "repo@sha256:abc", policy: {} }] }] };`, ); const capability = fakeCapability("verify"); const registry = new CapabilityRegistry(); registry.register(capability); const result = await runComponents(testDir, "svc", { registry, config: { signing: { oidcIssuer: "https://token.actions.githubusercontent.com", identity: "https://github.com/acme/repo/.github/workflows/release.yml@refs/heads/main", }, }, }); expect(result.success).toBe(true); expect(capability.calls[0]?.input).toMatchObject({ policy: { expectedIssuer: "https://token.actions.githubusercontent.com", expectedIdentity: "https://github.com/acme/repo/.github/workflows/release.yml@refs/heads/main", }, }); }); test("a per-step verify policy value overrides the configured default", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Verify", steps: [{ kind: "verify", imageRef: "repo@sha256:abc", policy: { expectedIssuer: "https://per-step-issuer.example.com" } }] }] };`, ); const capability = fakeCapability("verify"); const registry = new CapabilityRegistry(); registry.register(capability); await runComponents(testDir, "svc", { registry, config: { signing: { oidcIssuer: "https://token.actions.githubusercontent.com" } }, }); expect(capability.calls[0]?.input).toMatchObject({ policy: { expectedIssuer: "https://per-step-issuer.example.com" }, }); }); test("a vulnPolicy in config changes a vuln-gate step's effective policy before dispatch", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Gate", steps: [{ kind: "vuln-gate", sbom: { bytes: "", mediaType: "application/json", packageCount: 0, generator: "x", format: "spdx" } }] }] };`, ); const capability = fakeCapability("vuln-gate", { output: { passed: true, warnings: [], suppressed: [], licenseFindings: [] } }); const registry = new CapabilityRegistry(); registry.register(capability); await runComponents(testDir, "svc", { registry, config: { vulnPolicy: { failSeverity: "high" } }, }); expect(capability.calls[0]?.input).toMatchObject({ policy: { failSeverity: "high" } }); }); test("generate-sbom picks up sbom.format from config", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Build", steps: [{ kind: "generate-sbom", artifactType: "image", path: "img" }] }] };`, ); const capability = fakeCapability("generate-sbom"); const registry = new CapabilityRegistry(); registry.register(capability); await runComponents(testDir, "svc", { registry, config: { sbom: { format: "cyclonedx" } }, }); expect(capability.calls[0]?.input).toMatchObject({ format: "cyclonedx" }); }); }); // ── build-time parameters (chant #1108) ────────────────────────────────────── // // Before #1108, `discoverComponents`'s callers here never resolved // `chant.config.ts`'s declared `buildParams` at all, so a `*.component.ts` // file reading `params.` (`@intentius/chant/params`) always saw `{}` — // the exact probe from chant#1108's reproduction. These tests lock in that // `runComponents`/`resolveComponentTargets`/`generateComponentsPipeline` now // forward an already-resolved `buildParams` into discovery, so a component's // `params.` read reflects it before any step dispatches. const thisDir = dirname(fileURLToPath(import.meta.url)); const paramsModulePath = resolvePath(thisDir, "../params"); describe("runComponents — buildParams (chant #1108)", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-run-components-buildparams-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("the previously-{} case: a component's params. read resolves when buildParams is passed", async () => { await writeFile( join(testDir, "svc.component.ts"), ` import { params } from ${JSON.stringify(paramsModulePath)}; export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier }] }], }; `, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); const result = await runComponents(testDir, "svc", { registry, buildParams: [{ name: "tier", value: "production", source: "cli" }], }); expect(result.success).toBe(true); expect(capability.calls[0]?.input).toMatchObject({ tier: "production" }); }); test("without buildParams, params. is undefined (the bug this issue fixes, still true for a caller that resolves none)", async () => { await writeFile( join(testDir, "svc.component.ts"), ` import { params } from ${JSON.stringify(paramsModulePath)}; export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier ?? "unset" }] }], }; `, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); const result = await runComponents(testDir, "svc", { registry }); expect(result.success).toBe(true); expect(capability.calls[0]?.input).toMatchObject({ tier: "unset" }); }); test("RunComponentsResult.buildParams echoes back the resolved provenance that was passed in", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const result = await runComponents(testDir, "svc", { registry, buildParams: [{ name: "tier", value: "production", source: "cli" }], }); expect(result.buildParams).toEqual([{ name: "tier", value: "production", source: "cli" }]); }); test("selector 'all' also forwards buildParams into discovery", async () => { await writeFile( join(testDir, "svc.component.ts"), ` import { params } from ${JSON.stringify(paramsModulePath)}; export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier }] }] }; `, ); const capability = fakeCapability("deploy-thing"); const registry = new CapabilityRegistry(); registry.register(capability); const result = await runComponents(testDir, "all", { registry, buildParams: [{ name: "tier", value: "staging", source: "env" }], }); expect(result.success).toBe(true); expect(capability.calls[0]?.input).toMatchObject({ tier: "staging" }); }); }); describe("resolveComponentTargets — buildParams (chant #1108)", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-resolve-targets-buildparams-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("forwards buildParams into discovery — the durable (--temporal) path's entrypoint", async () => { await writeFile( join(testDir, "svc.component.ts"), ` import { params } from ${JSON.stringify(paramsModulePath)}; export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: params.env }] }], }; `, ); const result = await resolveComponentTargets(testDir, "svc", undefined, [ { name: "env", value: "prod-a", source: "env" }, ]); expect(result.success).toBe(true); expect((result.targets[0].deploy[0].steps[0] as { stack?: unknown }).stack).toBe("prod-a"); }); }); describe("generateComponentsPipeline — buildParams (chant #1108)", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-generate-components-buildparams-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("forwards buildParams into discovery before synthesizing the pipeline", async () => { await writeFile( join(testDir, "svc.component.ts"), ` import { params } from ${JSON.stringify(paramsModulePath)}; export const svc = { name: String(params.name), dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }], }; `, ); // Uses the mocked "gitlab" plugin above (see the `vi.mock` at the top of // this file) — proves the component name `discoverComponents` resolved // (via `params.name`) made it all the way through to the synthesized // pipeline, not just to `discover()`'s own return value. const result = await generateComponentsPipeline( testDir, "gitlab", undefined, undefined, [{ name: "name", value: "named-from-params", source: "cli" }], ); expect(result.success).toBe(true); expect(result.jobs?.map((j) => j.component)).toEqual(["named-from-params"]); expect(result.buildParams).toEqual([{ name: "name", value: "named-from-params", source: "cli" }]); }); }); describe("runComponents — onProgress (--progress-json wiring, M3)", () => { let testDir: string; beforeEach(async () => { testDir = join(tmpdir(), `chant-run-components-progress-test-${Date.now()}-${Math.random()}`); await mkdir(testDir, { recursive: true }); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); test("selector 'all': onProgress sees the same run-start/wave-*/component-*/run-done envelope runInterpretDriver emits", async () => { await writeFile( join(testDir, "alb.component.ts"), `export const alb = { name: "shared-alb", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "search-service", dependsOn: ["shared-alb"], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const events: RunProgressEvent[] = []; const result = await runComponents(testDir, "all", { registry, onProgress: (e) => events.push(e) }); expect(result.success).toBe(true); expect(events[0]).toEqual({ type: "run-start", waves: [["shared-alb"], ["search-service"]] }); expect(events.at(-1)).toEqual({ type: "run-done", status: "ok" }); expect(events.filter((e) => e.type === "component-start")).toEqual([ { type: "component-start", wave: 1, component: "shared-alb" }, { type: "component-start", wave: 2, component: "search-service" }, ]); }); test("single-component selector: onProgress still gets a well-formed single-wave envelope (bypasses runInterpretDriver)", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry(); const events: RunProgressEvent[] = []; const result = await runComponents(testDir, "svc", { registry, onProgress: (e) => events.push(e) }); expect(result.success).toBe(true); expect(events.map((e) => e.type)).toEqual([ "run-start", "wave-start", "component-start", "phase-start", "step", "step", "phase-done", "component-done", "wave-done", "run-done", ]); expect(events[0]).toEqual({ type: "run-start", waves: [["svc"]] }); expect(events[2]).toEqual({ type: "component-start", wave: 1, component: "svc" }); expect(events.at(-1)).toEqual({ type: "run-done", status: "ok" }); }); test("single-component selector: a failing step still yields component-done/wave-done/run-done status:\"failed\"", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const registry = fakeRegistry({ failRun: true }); const events: RunProgressEvent[] = []; const result = await runComponents(testDir, "svc", { registry, onProgress: (e) => events.push(e) }); expect(result.success).toBe(false); expect(events.at(-3)).toEqual({ type: "component-done", wave: 1, component: "svc", status: "failed" }); expect(events.at(-2)).toEqual({ type: "wave-done", wave: 1, status: "failed" }); expect(events.at(-1)).toEqual({ type: "run-done", status: "failed" }); }); test("no onProgress passed → no behavior change (result identical, and it's simply never called)", async () => { await writeFile( join(testDir, "svc.component.ts"), `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`, ); const result = await runComponents(testDir, "svc", { registry: fakeRegistry() }); expect(result.success).toBe(true); expect(result.run?.ok).toBe(true); }); });