import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { promisify } from "node:util"; import { classifyGlobalChange, componentModelsForServices, createG14CiPlan, matchingPaths, packageRuntimeFieldsChanged } from "./src/g14-ci-plan-lib.mjs"; const execFileAsync = promisify(execFile); test("G14 CI planner maps bridge changes to cloud-api only", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change bridge"]); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); assert.equal(plan.reusedServices.includes("hwlab-cloud-web"), true); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.affected, true); assert.deepEqual(cloudApi.reason, ["component-path-changed"]); }); test("G14 CI planner treats docs-only changes as no image build", async () => { const repo = await createFixtureRepo(); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change docs"]); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, []); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.changedPathSummary.docsOnly, true); }); test("planner rebuilds when catalog digest is missing instead of blocking reuse", async () => { const repo = await createFixtureRepo({ catalog: "missing-digest" }); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change docs"]); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.equal(plan.imageBuildRequired, true); assert.equal(plan.services[0].reason.includes("catalog-digest-missing"), true); }); test("component model uses built-in service paths", () => { const models = componentModelsForServices(["hwlab-cloud-api"]); assert.deepEqual(models[0].componentPaths, [ "cmd/hwlab-cloud-api/", "cmd/hwlab-codex-api-responses-forwarder/", "cmd/hwlab-deepseek-responses-bridge/", "internal/audit/", "internal/cloud/", "internal/db/", "skills/hwlab-agent-runtime/" ]); assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]); }); test("planner scopes cloud-web runtime changes to cloud-web", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 2;\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change cloud web runtime"]); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]); assert.equal(plan.buildServices.includes("hwlab-agent-worker"), false); const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); assert.deepEqual(cloudWeb.reason, ["component-path-changed"]); }); test("planner ignores package script cleanup for runtime images", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate" }, dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "seed package scripts"]); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate", check: "node scripts/check-runner.mjs --profile check" }, dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "change package scripts"]); assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.buildSkippedCount, 3); }); test("planner treats dependency changes as runtime image inputs", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "seed dependencies"]); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", dependencies: { "@openai/codex": "^0.129.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "change dependencies"]); assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"]); assert.equal(plan.imageBuildRequired, true); }); test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => { const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true }); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change bridge"]); const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); assert.equal(plan.compatibility.currentRenderCompatible, true); assert.equal(plan.compatibility.serviceIdSource, "deploy.k3s.serviceMappings"); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); assert.equal(plan.inputs.serviceCount, 2); }); test("global change classifier distinguishes gitops-only and test-only", () => { assert.equal(classifyGlobalChange(["deploy/gitops/g14/runtime-dev/workloads.yaml"]).gitopsOnly, true); assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/runtime-options.test.ts"]).testOnly, true); }); test("v02 planner marks device-pod code-only change as env reuse rollout", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createG14CiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-device-pod"] }); const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { devicePodEnvironmentInputHash: initialDevicePod.environmentInputHash, devicePodCodeInputHash: initialDevicePod.codeInputHash, devicePodBootCommit: initialPlan.sourceCommitId }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); await writeFile(path.join(repo, "internal/device-pod/executor.ts"), "export const version = 2;\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change device pod code"]); const plan = await createG14CiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-device-pod"] }); const devicePod = plan.services.find((service) => service.serviceId === "hwlab-device-pod"); assert.equal(devicePod.runtimeMode, "env-reuse-git-mirror-checkout"); assert.equal(devicePod.envChanged, false); assert.equal(devicePod.codeChanged, true); assert.equal(devicePod.buildRequired, false); assert.equal(devicePod.bootSh, "deploy/runtime/boot/hwlab-device-pod.sh"); assert.deepEqual(plan.affectedServices, ["hwlab-device-pod"]); assert.deepEqual(plan.rolloutServices, ["hwlab-device-pod"]); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); assert.deepEqual(plan.ciCdPlan.willBuild, []); assert.deepEqual(plan.ciCdPlan.willRollout, ["hwlab-device-pod"]); assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); }); test("v02 planner keeps CLI and host skill changes out of unrelated service builds", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createG14CiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"] }); const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { devicePodEnvironmentInputHash: initialDevicePod.environmentInputHash, devicePodCodeInputHash: initialDevicePod.codeInputHash, devicePodBootCommit: initialPlan.sourceCommitId }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); await writeFile(path.join(repo, "tools/hwlab-cli/client.test.ts"), "console.log('cli test');\n"); await writeFile(path.join(repo, "tools/src/hwlab-cli-lib.ts"), "export const harness = true;\n"); await writeFile(path.join(repo, "skills/device-pod-cli/assets/device-host-cli.mjs"), "console.log('host cli');\n"); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate", check: "node scripts/check-runner.mjs --profile check" } }, null, 2)); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change cli and host asset"]); const plan = await createG14CiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"] }); assert.deepEqual(plan.buildServices, ["hwlab-agent-skills"]); assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills"]); assert.equal(plan.buildSkippedCount, 3); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-device-pod").affected, false); }); async function createFixtureRepo(options = {}) { const deployServices = options.deployServices !== false; const k3sServiceMappings = options.k3sServiceMappings === true; const includeDevicePod = options.includeDevicePod === true; const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-g14-ci-plan-")); await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true }); await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true }); await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true }); await mkdir(path.join(repo, "internal/device-pod"), { recursive: true }); await mkdir(path.join(repo, "tools/hwlab-cli"), { recursive: true }); await mkdir(path.join(repo, "tools/src"), { recursive: true }); await mkdir(path.join(repo, "skills/device-pod-cli/assets"), { recursive: true }); await mkdir(path.join(repo, "skills/hwlab-agent-runtime"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true }); await mkdir(path.join(repo, "deploy"), { recursive: true }); await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }), null, 2)); const catalogMode = options.catalog ?? "ready"; if (catalogMode !== false) { await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2)); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2)); } await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2)); await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2)); await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "export const proxy = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n"); await writeFile(path.join(repo, "internal/device-pod/executor.ts"), "export const version = 1;\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-device-pod.sh"), "#!/bin/sh\nexec bun cmd/hwlab-device-pod/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); await git(repo, ["init"]); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "initial"]); return repo; } function createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }) { const deploy = { lanes: { v02: { sourceRepo: "git@github.com:pikasTech/HWLAB.git", envReuseServices: ["hwlab-device-pod"], bootScripts: { "hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh" } } } }; if (deployServices) { deploy.services = [ { serviceId: "hwlab-cloud-api" }, { serviceId: "hwlab-cloud-web" }, ...(includeDevicePod ? [{ serviceId: "hwlab-device-pod" }] : []) ]; } if (k3sServiceMappings) { deploy.k3s = { serviceMappings: [ { serviceId: "hwlab-cloud-api", name: "hwlab-cloud-api" }, { serviceId: "hwlab-cloud-web", name: "hwlab-cloud-web" }, ...(includeDevicePod ? [{ serviceId: "hwlab-device-pod", name: "hwlab-device-pod" }] : []) ] }; } return deploy; } function createCatalogFixture(mode, options = {}) { const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published"; return { services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"].map((serviceId) => ({ serviceId, commitId: "abc1234", image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`, imageTag: "abc1234", digest, ...(serviceId === "hwlab-device-pod" ? { runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, environmentImage: "127.0.0.1:5000/hwlab/hwlab-device-pod-env:env-abc1234", environmentDigest: digest, environmentInputHash: options.devicePodEnvironmentInputHash ?? "e".repeat(64), codeInputHash: options.devicePodCodeInputHash ?? "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: options.devicePodBootCommit ?? "abc1234", bootSh: "deploy/runtime/boot/hwlab-device-pod.sh" } : {}) })) }; } async function git(cwd, args) { return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd }); }