608 lines
31 KiB
JavaScript
608 lines
31 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, mkdtemp, readFile, 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("planner rebuilds service image when catalog component input hash is stale", async () => {
|
|
const repo = await createFixtureRepo({ includeDevicePod: true });
|
|
const initialPlan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
lane: "v02",
|
|
baseRef: "HEAD",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
|
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"]
|
|
});
|
|
const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
|
const staleHash = initialCloudApi.componentInputHash;
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
|
sourceCommitId: initialPlan.sourceCommitId,
|
|
cloudApiComponentInputHash: staleHash
|
|
}), null, 2));
|
|
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
|
await git(repo, ["commit", "-m", "seed catalog with current cloud-api hash"]);
|
|
|
|
await writeFile(path.join(repo, "tools/src/device-pod-cli-lib.ts"), "export const hwpod = true;\n");
|
|
await git(repo, ["add", "tools/src/device-pod-cli-lib.ts"]);
|
|
await git(repo, ["commit", "-m", "change shared hwpod runtime"]);
|
|
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
|
|
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "document planner reuse invariant\n");
|
|
await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]);
|
|
await git(repo, ["commit", "-m", "document planner reuse invariant"]);
|
|
|
|
const plan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
lane: "v02",
|
|
baseRef: "HEAD~1",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
|
services: ["hwlab-cloud-api"]
|
|
});
|
|
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
|
assert.equal(cloudApi.affected, true);
|
|
assert.equal(cloudApi.buildRequired, true);
|
|
assert.equal(cloudApi.componentInputChanged, true);
|
|
assert.equal(cloudApi.reuse.status, "component-input-mismatch");
|
|
assert.deepEqual(cloudApi.reason, ["component-input-mismatch"]);
|
|
assert.ok(plan.buildServices.includes("hwlab-cloud-api"));
|
|
});
|
|
|
|
test("planner rejects polluted catalog hash that does not match catalog source tree", async () => {
|
|
const repo = await createFixtureRepo({ includeDevicePod: true });
|
|
const oldSourceCommit = (await git(repo, ["rev-parse", "HEAD~1"])).stdout.trim();
|
|
|
|
await writeFile(path.join(repo, "tools/src/device-pod-cli-lib.ts"), "export const hwpod = true;\n");
|
|
await git(repo, ["add", "tools/src/device-pod-cli-lib.ts"]);
|
|
await git(repo, ["commit", "-m", "change shared hwpod runtime"]);
|
|
|
|
const currentPlan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
lane: "v02",
|
|
baseRef: "HEAD",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
|
services: ["hwlab-cloud-api"]
|
|
});
|
|
const currentCloudApi = currentPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
|
sourceCommitId: oldSourceCommit,
|
|
cloudApiComponentInputHash: currentCloudApi.componentInputHash
|
|
}), null, 2));
|
|
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
|
await git(repo, ["commit", "-m", "pollute catalog provenance"]);
|
|
|
|
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
|
|
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs only after provenance pollution\n");
|
|
await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]);
|
|
await git(repo, ["commit", "-m", "docs after polluted catalog"]);
|
|
|
|
const plan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
lane: "v02",
|
|
baseRef: "HEAD~1",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
|
services: ["hwlab-cloud-api"]
|
|
});
|
|
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
|
assert.equal(cloudApi.affected, true);
|
|
assert.equal(cloudApi.buildRequired, true);
|
|
assert.equal(cloudApi.reuse.status, "component-source-mismatch");
|
|
assert.deepEqual(cloudApi.reason, ["component-source-mismatch"]);
|
|
});
|
|
|
|
test("artifact reuse paths preserve catalog provenance instead of current planner provenance", async () => {
|
|
const artifactPublish = await readFile("scripts/artifact-publish.mjs", "utf8");
|
|
assert.match(artifactPublish, /const catalogProvenance = serviceImageCatalogProvenance\(catalogService\);/u);
|
|
assert.match(artifactPublish, /\.\.\.catalogProvenance,[\s\S]{0,80}buildBackend: "reused-catalog"/u);
|
|
assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u);
|
|
|
|
const gitopsRender = await readFile("scripts/g14-gitops-render.mjs", "utf8");
|
|
assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
|
|
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
|
|
});
|
|
|
|
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 refreshFixtureCatalogForHead(repo, { includeDevicePod: true });
|
|
|
|
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 marks cloud-web 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-cloud-web"]
|
|
});
|
|
const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
|
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
|
|
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
|
|
cloudWebBootCommit: initialPlan.sourceCommitId
|
|
}), null, 2));
|
|
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
|
await git(repo, ["commit", "-m", "seed v02 cloud web catalog"]);
|
|
|
|
await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html>v2</html>\n");
|
|
await git(repo, ["add", "."]);
|
|
await git(repo, ["commit", "-m", "change cloud web code"]);
|
|
|
|
const plan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
lane: "v02",
|
|
baseRef: "HEAD~1",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
|
services: ["hwlab-cloud-web"]
|
|
});
|
|
const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
|
assert.equal(cloudWeb.runtimeMode, "env-reuse-git-mirror-checkout");
|
|
assert.equal(cloudWeb.envChanged, false);
|
|
assert.equal(cloudWeb.codeChanged, true);
|
|
assert.equal(cloudWeb.buildRequired, false);
|
|
assert.equal(cloudWeb.bootSh, "deploy/runtime/boot/hwlab-cloud-web.sh");
|
|
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
|
|
assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]);
|
|
assert.deepEqual(plan.buildServices, []);
|
|
assert.equal(plan.imageBuildRequired, false);
|
|
assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
|
|
});
|
|
|
|
test("v02 planner keeps hwlab-cli-only changes out of runtime 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 initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
|
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
|
|
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
|
sourceCommitId: initialPlan.sourceCommitId,
|
|
componentInputHashes: initialHashes,
|
|
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
|
|
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
|
|
cloudWebBootCommit: initialPlan.sourceCommitId,
|
|
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/src/hwlab-cli-lib.ts"), "export const harness = true;\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);
|
|
});
|
|
|
|
test("v02 planner treats hwpod runner CLI changes as runtime inputs", 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 initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
|
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
|
|
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
|
sourceCommitId: initialPlan.sourceCommitId,
|
|
componentInputHashes: initialHashes,
|
|
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
|
|
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
|
|
cloudWebBootCommit: initialPlan.sourceCommitId,
|
|
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/src/device-pod-cli-lib.ts"), "export const hwpod = true;\n");
|
|
await writeFile(path.join(repo, "skills/device-pod-cli/SKILL.md"), "hwpod skill v2\n");
|
|
await git(repo, ["add", "."]);
|
|
await git(repo, ["commit", "-m", "change hwpod runtime cli"]);
|
|
|
|
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.affectedServices, ["hwlab-agent-skills", "hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"]);
|
|
assert.deepEqual(plan.buildServices, ["hwlab-agent-skills", "hwlab-cloud-api"]);
|
|
assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").codeChanged, true);
|
|
assert.equal(plan.services.find((service) => service.serviceId === "hwlab-device-pod").codeChanged, true);
|
|
assert.deepEqual(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").reason, ["shared-runtime-changed"]);
|
|
});
|
|
|
|
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/device-pod-cli/scripts"), { 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";
|
|
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"), "<html></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, "tools/device-pod-cli.mjs"), "console.log('hwpod wrapper');\n");
|
|
await writeFile(path.join(repo, "tools/device-pod-cli.ts"), "console.log('hwpod ts');\n");
|
|
await writeFile(path.join(repo, "tools/src/device-pod-cli-lib.ts"), "export const hwpod = false;\n");
|
|
await writeFile(path.join(repo, "skills/device-pod-cli/SKILL.md"), "hwpod skill\n");
|
|
await writeFile(path.join(repo, "skills/device-pod-cli/scripts/device-pod-cli.mjs"), "console.log('skill wrapper');\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, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\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"]);
|
|
if (catalogMode !== false) {
|
|
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
|
const initialPlan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
baseRef: "HEAD",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
|
services: ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])]
|
|
});
|
|
const componentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
|
const catalog = createCatalogFixture(catalogMode, { sourceCommitId: initialSha, componentInputHashes });
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
|
|
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
|
|
await git(repo, ["commit", "-m", "seed artifact catalogs"]);
|
|
}
|
|
return repo;
|
|
}
|
|
|
|
async function refreshFixtureCatalogForHead(repo, { includeDevicePod = true } = {}) {
|
|
const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
|
const services = ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])];
|
|
const plan = await createG14CiPlan({
|
|
repoRoot: repo,
|
|
baseRef: "HEAD",
|
|
targetRef: "HEAD",
|
|
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
|
services
|
|
});
|
|
const componentInputHashes = Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
|
const catalog = createCatalogFixture("ready", { sourceCommitId, componentInputHashes });
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
|
|
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
|
|
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
|
|
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
|
|
}
|
|
|
|
function createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }) {
|
|
const deploy = {
|
|
lanes: {
|
|
v02: {
|
|
sourceRepo: "git@github.com:pikasTech/HWLAB.git",
|
|
envReuseServices: ["hwlab-cloud-web", "hwlab-device-pod"],
|
|
bootScripts: {
|
|
"hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
|
|
"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",
|
|
sourceCommitId: options.sourceCommitId ?? "abc1234",
|
|
image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`,
|
|
imageTag: "abc1234",
|
|
digest,
|
|
...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? {
|
|
componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId]
|
|
} : {}),
|
|
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? {
|
|
runtimeMode: "env-reuse-git-mirror-checkout",
|
|
envReuse: true,
|
|
environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`,
|
|
environmentDigest: digest,
|
|
environmentInputHash: serviceId === "hwlab-cloud-web"
|
|
? options.cloudWebEnvironmentInputHash ?? "e".repeat(64)
|
|
: options.devicePodEnvironmentInputHash ?? "e".repeat(64),
|
|
codeInputHash: serviceId === "hwlab-cloud-web"
|
|
? options.cloudWebCodeInputHash ?? "c".repeat(64)
|
|
: options.devicePodCodeInputHash ?? "c".repeat(64),
|
|
bootRepo: "git@github.com:pikasTech/HWLAB.git",
|
|
bootCommit: serviceId === "hwlab-cloud-web"
|
|
? options.cloudWebBootCommit ?? "abc1234"
|
|
: options.devicePodBootCommit ?? "abc1234",
|
|
bootSh: `deploy/runtime/boot/${serviceId}.sh`
|
|
} : {})
|
|
}))
|
|
};
|
|
}
|
|
|
|
async function git(cwd, args) {
|
|
return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd });
|
|
}
|