ktworkflow-mcp/src/__tests__/unit/napcatAutomation.test.ts

912 lines
31 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import test, { after } from "node:test";
import {
parseNapcatRemoteDevHandoffCliArgs,
parseNapcatRuntimeReleaseReadinessCliArgs,
parseNapcatSyncCandidateReviewCliArgs,
parseNapcatUpstreamAuditCliArgs,
parseNasCodexBootstrapCliArgs,
} from "../../core/cli.js";
import { registeredToolNames } from "../../core/constants.js";
import { resolveInsideRoot } from "../../core/workspace.js";
import { registerTools } from "../../registerTools.js";
import {
loadNapcatAutomationPrompt,
type NapcatAutomationPromptName,
napcatAutomationPromptNames,
} from "../../tools/napcatAutomation.prompts.js";
import {
napcatAutomationReasonCodes,
parseNapcatAutomationClassification,
upstreamAuditOutputSchema,
} from "../../tools/napcatAutomation.types.js";
import {
buildNapcatCodexExecCommand,
buildNapcatRemoteDevHandoff,
buildNapcatRuntimeReleaseReadiness,
buildNapcatSyncCandidateReview,
buildNapcatUpstreamAudit,
buildNasCodexBootstrapPlan,
classifyNapcatUpstreamAudit,
napcatAutomationDefaults,
shouldFailNapcatAutomationCli,
} from "../../tools/napcatAutomation.js";
const NAPCAT_TEST_ARTIFACT_ROOT = resolveInsideRoot(
".kt-workspace/test-artifacts/napcat-automation-unit",
);
/**
* Removes the complete artifact namespace owned by this test module.
* @returns Nothing; cleanup is idempotent and never touches sibling evidence.
*/
function cleanupNapcatTestArtifacts(): void {
rmSync(NAPCAT_TEST_ARTIFACT_ROOT, { force: true, recursive: true });
}
after(cleanupNapcatTestArtifacts);
/**
* Reads a UTF-8 ktWorkflow fixture through the guarded KT workspace resolver.
* @param relativePath - Workspace-relative path to the source or CI template.
* @returns Normalized text with LF line endings for stable substring assertions.
*/
function readWorkspaceText(relativePath: string): string {
return readFileSync(resolveInsideRoot(relativePath), "utf8").replace(/\r\n/g, "\n");
}
/**
* Asserts that every required safety marker remains present in generated text.
* @param content - Command or template text under review.
* @param expectedValues - Required markers whose absence would weaken the contract.
* @param label - Human-readable contract name included in assertion diagnostics.
* @returns Nothing; the assertion fails on the first missing marker.
*/
function assertIncludesAll(
content: string,
expectedValues: readonly string[],
label: string,
): void {
for (const expected of expectedValues) {
assert.ok(content.includes(expected), `${label} missing: ${expected}`);
}
}
/**
* Asserts that mutation commands or unsafe configuration markers do not leak into text.
* @param content - Command or template text under review.
* @param forbiddenValues - Text fragments prohibited by the workflow boundary.
* @param label - Human-readable contract name included in assertion diagnostics.
* @returns Nothing; the assertion fails on the first forbidden marker.
*/
function assertExcludesAll(
content: string,
forbiddenValues: readonly string[],
label: string,
): void {
for (const forbidden of forbiddenValues) {
assert.ok(!content.includes(forbidden), `${label} leaked: ${forbidden}`);
}
}
/**
* Verifies that a NapCat Jenkins template uses the NAS node and mounted helper container.
* @param template - Full Jenkinsfile text.
* @param label - Template identity used in assertion diagnostics.
* @returns Nothing; assertions fail when the NAS execution topology regresses.
*/
function assertUsesNasWorkflowRunner(template: string, label: string): void {
assert.ok(
template.includes("agent { label 'kt-node-agent' }"),
`${label} must target kt-node-agent`,
);
assertIncludesAll(
template,
[
"KT_WORKFLOW_RUNNER_IMAGE",
"docker run --rm",
"/vol1/docker/kt-codex:/vol1/docker/kt-codex",
"COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack",
"corepack prepare pnpm@10.28.2 --activate",
],
`${label} NAS helper runner`,
);
}
/**
* Proves that the default upstream-audit Jenkins stage stays dry-run before Codex execution.
* @param template - Full upstream-sync Jenkinsfile text.
* @returns Nothing; assertions fail when stage ordering or safe flags regress.
*/
function assertUpstreamDefaultAuditIsDryRun(template: string): void {
const defaultStageStart = template.indexOf(
"stage('ktWorkflow upstream audit dry-run')",
);
const codexStageStart = template.indexOf(
"stage('ktWorkflow upstream audit with Codex')",
);
assert.notEqual(defaultStageStart, -1);
assert.notEqual(codexStageStart, -1);
assert.ok(defaultStageStart < codexStageStart);
const defaultStage = template.slice(defaultStageStart, codexStageStart);
assert.ok(defaultStage.includes("run napcat-upstream-audit --"));
assertExcludesAll(defaultStage, ["--execute", "--use-codex"], "default audit stage");
}
/** Verifies strict NapCat schemas, CLI parsing, and terminal failure classification. */
test("preserves strict NapCat automation schemas and CLI gates", () => {
assert.equal(
upstreamAuditOutputSchema.safeParse({
classification: "manual-review",
reasonCodes: ["HOT_ZONE_CHANGED"],
recommendedAction: "request-human-review",
summary: "Login hot-zone changed.",
}).success,
true,
);
assert.equal(
upstreamAuditOutputSchema.safeParse({
classification: "manual-review",
reasonCodes: ["HOT_ZONE_CHANGED"],
recommendedAction: "request-human-review",
summary: "Login hot-zone changed.",
unexpectedField: "Codex output schema rejects this.",
}).success,
false,
);
assert.ok(napcatAutomationReasonCodes.includes("HOT_ZONE_CHANGED"));
const upstreamCli = parseNapcatUpstreamAuditCliArgs([
"node",
"server",
"--napcat-upstream-audit",
"--execute",
"--use-codex",
"--artifact-root",
"/vol1/docker/kt-codex/artifacts/napcat-upstream-sync",
]);
assert.equal(upstreamCli.execute, true);
assert.equal(upstreamCli.useCodex, true);
assert.equal(
upstreamCli.artifactRoot,
"/vol1/docker/kt-codex/artifacts/napcat-upstream-sync",
);
for (const { expected, input } of [
{
expected: true,
input: { classification: "blocked", recommendedAction: "block" },
},
{
expected: false,
input: {
classification: "manual-review",
recommendedAction: "request-human-review",
},
},
{
expected: false,
input: {
classification: "safe-candidate",
recommendedAction: "create-candidate-branch",
},
},
]) {
assert.equal(shouldFailNapcatAutomationCli(input), expected);
}
const auxiliaryCliResults = [
parseNapcatSyncCandidateReviewCliArgs([
"node",
"server",
"--napcat-sync-candidate-review",
]),
parseNapcatRuntimeReleaseReadinessCliArgs([
"node",
"server",
"--napcat-runtime-release-readiness",
]),
parseNapcatRemoteDevHandoffCliArgs([
"node",
"server",
"--napcat-remote-dev-handoff",
]),
parseNasCodexBootstrapCliArgs(["node", "server", "--nas-codex-bootstrap"]),
];
assert.ok(auxiliaryCliResults.every((result) => result.execute === false));
assert.equal(
parseNapcatAutomationClassification("manual-review"),
"manual-review",
);
});
/** Verifies isolated skeleton artifacts and alignment with the shared default contract. */
test("aligns NapCat skeleton builders with defaults and rejects unsafe roots", () => {
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "defaults");
const unsafeRoot = path.resolve("/tmp/not-kt-skeleton");
const unsafeRootExisted = existsSync(unsafeRoot);
rmSync(artifactBase, { force: true, recursive: true });
try {
const candidate = buildNapcatSyncCandidateReview({
artifactRoot: path.join(artifactBase, "candidate"),
});
const runtime = buildNapcatRuntimeReleaseReadiness({
artifactRoot: path.join(artifactBase, "runtime"),
});
const remote = buildNapcatRemoteDevHandoff({
artifactRoot: path.join(artifactBase, "remote"),
});
const bootstrap = buildNasCodexBootstrapPlan({
artifactRoot: path.join(artifactBase, "bootstrap"),
execute: false,
});
assert.deepEqual(candidate.context, {
candidateBranch: napcatAutomationDefaults.candidateBranch,
forkBranch: napcatAutomationDefaults.forkBranch,
forkRepo: napcatAutomationDefaults.forkRepo,
lastAcceptedUpstreamBase: napcatAutomationDefaults.lastAcceptedUpstreamBase,
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
upstreamRepo: napcatAutomationDefaults.upstreamRepo,
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
});
assert.deepEqual(runtime.context, {
apiImageTag: napcatAutomationDefaults.apiImageTag,
candidateBranch: napcatAutomationDefaults.candidateBranch,
forkBranch: napcatAutomationDefaults.forkBranch,
forkRepo: napcatAutomationDefaults.forkRepo,
napcatImageTag: napcatAutomationDefaults.runtimeImageTag,
profile: napcatAutomationDefaults.runtimeProfile,
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
});
assert.deepEqual(remote.context, {
candidateBranch: napcatAutomationDefaults.candidateBranch,
forkBranch: napcatAutomationDefaults.forkBranch,
forkRepo: napcatAutomationDefaults.forkRepo,
targetHost: napcatAutomationDefaults.targetHost,
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
});
assert.equal(bootstrap.context.codexHome, napcatAutomationDefaults.codexHome);
assert.equal(
bootstrap.context.serviceName,
napcatAutomationDefaults.bootstrapServiceName,
);
assert.equal(
bootstrap.context.workspaceRoot,
napcatAutomationDefaults.workspaceRoot,
);
for (const artifact of [
...candidate.artifacts,
...runtime.artifacts,
...remote.artifacts,
]) {
assert.ok(existsSync(artifact.path), `missing skeleton artifact: ${artifact.path}`);
}
assert.throws(
() =>
buildNapcatRuntimeReleaseReadiness({
artifactRoot: "/tmp/not-kt-skeleton",
}),
/Unsafe NapCat Codex artifactRoot/,
);
if (!unsafeRootExisted) {
assert.equal(existsSync(unsafeRoot), false);
}
} finally {
rmSync(artifactBase, { force: true, recursive: true });
if (!unsafeRootExisted) {
rmSync(unsafeRoot, { force: true, recursive: true });
}
}
});
/** Verifies caller overrides replace, rather than supplement, shared NapCat defaults. */
test("propagates NapCat command overrides without stale default refs", () => {
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "overrides");
rmSync(artifactBase, { force: true, recursive: true });
try {
const candidateText = buildNapcatSyncCandidateReview({
artifactRoot: path.join(artifactBase, "candidate"),
candidateBranch: "custom-candidate",
lastAcceptedUpstreamBase: "custom-base",
}).commands.map((item) => item.command).join("\n");
assertIncludesAll(
candidateText,
["custom-candidate", "custom-base"],
"candidate override",
);
assertExcludesAll(
candidateText,
[
napcatAutomationDefaults.candidateBranch,
napcatAutomationDefaults.lastAcceptedUpstreamBase,
],
"candidate override",
);
const runtimeText = buildNapcatRuntimeReleaseReadiness({
artifactRoot: path.join(artifactBase, "runtime"),
napcatImageTag: "custom-image",
}).commands.map((item) => item.command).join("\n");
assert.ok(runtimeText.includes("custom-image"));
assert.ok(!runtimeText.includes(napcatAutomationDefaults.runtimeImageTag));
const auditText = buildNapcatUpstreamAudit({
dryMergeConflict: false,
forkBranch: "custom-fork",
forkPatchFiles: [],
lastAcceptedUpstreamBase: "custom-base",
upstreamChangedFiles: [],
upstreamReleaseTag: "custom-upstream",
}).commands.map((item) => item.command).join("\n");
assertIncludesAll(
auditText,
["custom-fork", "custom-base", "custom-upstream"],
"upstream audit override",
);
assertExcludesAll(
auditText,
[
napcatAutomationDefaults.forkBranch,
napcatAutomationDefaults.lastAcceptedUpstreamBase,
napcatAutomationDefaults.upstreamReleaseRef,
],
"upstream audit override",
);
} finally {
rmSync(artifactBase, { force: true, recursive: true });
}
});
/** Verifies dry-run and execute-mode NAS bootstrap command safety. */
test("keeps NAS Codex bootstrap bounded to trusted Linux preparation", () => {
const dryRun = buildNasCodexBootstrapPlan({ execute: false });
const dryRunText = dryRun.commands.map((item) => item.command).join("\n");
assertIncludesAll(
dryRunText,
[
"/vol1/docker/kt-codex/home/.codex",
"export NVM_DIR=/vol1/docker/kt-codex/runtime/nvm",
'test -s "$NVM_DIR/nvm.sh"',
"nvm --version",
"nvm current",
"node --version",
"corepack --version",
"pnpm --version",
"git --version",
"docker --version",
"codex --version",
"test -d /vol1/docker/kt-codex/workspace/KT",
"test -d /vol1/docker/kt-codex/home/.codex",
"timeout --signal=TERM --kill-after=5s 130s ssh",
"-o BatchMode=yes",
"-o ConnectTimeout=10",
"-o StrictHostKeyChecking=yes",
"'timeout --signal=TERM --kill-after=5s 120s bash -s'",
"<<'KT_NAS_CODEX_BOOTSTRAP'",
],
"NAS bootstrap dry-run",
);
assert.match(dryRunText, /\nKT_NAS_CODEX_BOOTSTRAP$/);
assertExcludesAll(
dryRunText,
["$remoteScript", "tr -d", "PowerShell", "here-string"],
"NAS bootstrap dry-run",
);
assert.ok(dryRun.commands.every((command) => command.readOnly));
assert.ok(!dryRunText.includes("C:\\Users"));
const executePlan = buildNasCodexBootstrapPlan({ execute: true });
const executeText = executePlan.commands.map((item) => item.command).join("\n");
assert.equal(executePlan.execute, true);
assert.ok(executePlan.commands.some((command) => !command.readOnly));
assertIncludesAll(
executeText,
[
"install -d -m 700 /vol1/docker/kt-codex/home/.codex",
"install -d -m 755 /vol1/docker/kt-codex/runtime",
'git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR"',
'git -C "$NVM_DIR" checkout "$NVM_TAG"',
'nvm install "$NODE_VERSION"',
'nvm alias default "$NODE_VERSION"',
"npm install -g @openai/codex@latest",
'ln -sfn "$target" "/usr/local/bin/$cmd"',
"timeout --signal=TERM --kill-after=5s 310s ssh",
"'timeout --signal=TERM --kill-after=5s 300s bash -s'",
],
"NAS bootstrap execute plan",
);
assertExcludesAll(
executeText,
[
"git push",
"git merge",
"docker build",
"docker pull",
"kubectl",
"systemctl enable",
"systemctl start",
"cp ~/.codex",
"scp ~/.codex",
"C:\\Users",
"$remoteScript",
"tr -d",
"PowerShell",
"here-string",
],
"NAS bootstrap execute plan",
);
assert.throws(
() =>
buildNasCodexBootstrapPlan({
codexHome: "C:\\Users\\example\\.codex",
execute: false,
}),
/Unsafe NAS Codex bootstrap path/,
);
});
/** Verifies package scripts and the actual MCP registration surface for NapCat tools. */
test("registers NapCat automation scripts and MCP tools", () => {
const packageJson = JSON.parse(
readWorkspaceText("mcp/ktWorkflow/package.json"),
) as { scripts?: Record<string, string> };
assert.ok(packageJson.scripts?.["napcat-remote-dev-handoff"]);
assert.ok(packageJson.scripts?.["change-doc-sync"]);
const registeredByFakeServer: string[] = [];
const fakeMcpServer = {
/**
* Records tool names supplied by registerTools without starting an MCP server.
* @param name - Registered MCP tool name.
* @returns Nothing; the name is appended to the local evidence list.
*/
registerTool(name: string): void {
registeredByFakeServer.push(name);
},
};
registerTools(fakeMcpServer as never);
for (const toolName of [
"kt_napcat_upstream_audit",
"kt_napcat_sync_candidate_review",
"kt_napcat_runtime_release_readiness",
"kt_napcat_remote_dev_handoff",
"kt_nas_codex_bootstrap_plan",
] as const) {
assert.ok(registeredToolNames.includes(toolName));
assert.ok(registeredByFakeServer.includes(toolName));
}
});
/** Verifies deterministic audit classification, read-only commands, and path validation. */
test("keeps NapCat upstream audit dry-run read-only and rejects unsafe metadata", () => {
const hotZoneAudit = classifyNapcatUpstreamAudit({
dryMergeConflict: false,
forkPatchFiles: ["packages/napcat-core/login/runtime.ts"],
upstreamChangedFiles: ["packages/napcat-core/login/runtime.ts"],
});
assert.equal(hotZoneAudit.classification, "manual-review");
assert.ok(hotZoneAudit.reasonCodes.includes("HOT_ZONE_CHANGED"));
const dryRunAudit = buildNapcatUpstreamAudit({
dryMergeConflict: false,
forkPatchFiles: ["packages/napcat-core/runtime/base.ts"],
upstreamChangedFiles: ["packages/napcat-core/runtime/upstream.ts"],
});
assert.equal(dryRunAudit.execute, false);
assert.ok(dryRunAudit.artifacts.length > 0);
assert.ok(dryRunAudit.commands.every((command) => command.readOnly));
const commandText = dryRunAudit.commands.map((item) => item.command).join("\n");
assertIncludesAll(
commandText,
[
"git status --short --branch",
"git fetch --dry-run upstream --tags --prune",
"git fetch --dry-run origin --prune",
"git diff --name-only",
"git merge-tree",
`FORK_BRANCH:-${napcatAutomationDefaults.forkBranch}`,
`UPSTREAM_RELEASE_REF:-${napcatAutomationDefaults.upstreamReleaseRef}`,
],
"NapCat upstream audit dry-run",
);
assert.ok(!commandText.includes("FORK_BRANCH:-HEAD"));
for (const command of dryRunAudit.commands) {
if (/^git\s+fetch(?:\s|$)/.test(command.command)) {
assert.match(command.command, /\s--dry-run(?:\s|$)/);
}
}
for (const { label, regex } of [
{ label: "git merge", regex: /(^|\n)\s*git\s+merge(?:\s|$)/ },
{ label: "git push", regex: /(^|\n)\s*git\s+push(?:\s|$)/ },
{ label: "docker build", regex: /(^|\n)\s*docker\s+build(?:\s|$)/ },
{ label: "kubectl", regex: /(^|\n)\s*kubectl(?:\s|$)/ },
{ label: "deploy", regex: /(^|\n)\s*deploy(?:\s|$)/ },
]) {
assert.equal(regex.test(commandText), false, `dry-run leaked ${label}`);
}
const invalidPathCases = [
{
forkPatchFiles: [],
name: "Windows absolute path",
upstreamChangedFiles: ["D:/repo/packages/napcat-core/login/runtime.ts"],
},
{
forkPatchFiles: [],
name: "Unix absolute path",
upstreamChangedFiles: ["/repo/packages/napcat-core/login/runtime.ts"],
},
{
forkPatchFiles: [],
name: "UNC path",
upstreamChangedFiles: [
"\\\\server\\share\\packages\\napcat-core\\login\\runtime.ts",
],
},
{
forkPatchFiles: [],
name: "Windows rooted path",
upstreamChangedFiles: ["\\packages\\napcat-core\\login\\runtime.ts"],
},
{
forkPatchFiles: [],
name: "Windows drive-relative path",
upstreamChangedFiles: ["C:packages/napcat-core/login/runtime.ts"],
},
{
forkPatchFiles: [],
name: "parent segment path",
upstreamChangedFiles: ["packages/../napcat-core/login/runtime.ts"],
},
{
forkPatchFiles: ["packages/../napcat-core/login/runtime.ts"],
name: "fork patch parent segment path",
upstreamChangedFiles: [],
},
];
for (const invalidPathCase of invalidPathCases) {
const result = classifyNapcatUpstreamAudit({
dryMergeConflict: false,
forkPatchFiles: invalidPathCase.forkPatchFiles,
upstreamChangedFiles: invalidPathCase.upstreamChangedFiles,
});
assert.equal(result.classification, "blocked", invalidPathCase.name);
assert.equal(result.recommendedAction, "block", invalidPathCase.name);
assert.ok(
result.reasonCodes.includes("METADATA_UNAVAILABLE"),
invalidPathCase.name,
);
}
for (const promptName of napcatAutomationPromptNames) {
assert.ok(loadNapcatAutomationPrompt(promptName).includes("Do not edit files"));
}
assert.throws(
() =>
loadNapcatAutomationPrompt(
"../../README" as NapcatAutomationPromptName,
),
/Unsupported NapCat automation prompt/,
);
});
/** Verifies the bounded Codex CLI command shape used by upstream audit automation. */
test("builds a stdin-safe, non-deploying NapCat Codex command", () => {
const command = buildNapcatCodexExecCommand({
artifactRoot: "/vol1/docker/kt-codex/artifacts/smoke",
contextPacketPath:
"/vol1/docker/kt-codex/artifacts/smoke/context-packet.md",
outputSchemaPath:
"/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json",
promptName: "upstream-audit",
workspaceRoot: "/vol1/docker/kt-codex/workspace/KT",
});
assertIncludesAll(
command,
[
"--ask-for-approval never",
"codex --ask-for-approval never exec",
"CODEX_HOME='/vol1/docker/kt-codex/home/.codex'",
"--cd '/vol1/docker/kt-codex/workspace/KT'",
"--profile automation",
"--sandbox workspace-write",
"--json",
"--ephemeral",
"--output-schema '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json'",
"--output-last-message '/vol1/docker/kt-codex/artifacts/smoke/codex-upstream-audit.md'",
"- < '/vol1/docker/kt-codex/artifacts/smoke/context-packet.md'",
],
"NapCat Codex command",
);
assertExcludesAll(
command,
["codex exec", "$(cat", "git push", "kubectl"],
"NapCat Codex command",
);
});
/** Verifies Codex JSONL parsing, blocked fallbacks, artifact capture, and cleanup. */
test("captures NapCat Codex execution evidence and blocks invalid output", () => {
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "codex-execution");
const unsafeRoot = path.resolve("/tmp/not-kt");
const unsafeRootExisted = existsSync(unsafeRoot);
rmSync(artifactBase, { force: true, recursive: true });
try {
const invalidRoot = path.join(artifactBase, "invalid-schema");
const invalidAudit = buildNapcatUpstreamAudit({
artifactRoot: invalidRoot,
codexExecutor: () => ({
ok: true,
stderr: "schema warning",
stdout: '{"classification":"surprising"}\n',
}),
dryMergeConflict: false,
execute: true,
forkPatchFiles: [],
upstreamChangedFiles: [],
useCodex: true,
});
assert.equal(invalidAudit.classification, "blocked");
assert.ok(invalidAudit.reasonCodes.includes("CODEX_SCHEMA_INVALID"));
assert.ok(existsSync(path.join(invalidRoot, "codex-upstream-audit.jsonl")));
assert.ok(existsSync(path.join(invalidRoot, "context-packet.md")));
assert.equal(
readFileSync(
path.join(invalidRoot, "codex-upstream-audit.stderr.log"),
"utf8",
),
"schema warning",
);
const successAudit = buildNapcatUpstreamAudit({
artifactRoot: path.join(artifactBase, "item-text-success"),
codexExecutor: () => ({
ok: true,
stderr: "",
stdout: `${JSON.stringify({
item: {
text: JSON.stringify({
classification: "safe-candidate",
reasonCodes: ["NO_UPSTREAM_CHANGE"],
recommendedAction: "create-candidate-branch",
summary: "ok",
}),
type: "agent_message",
},
type: "item.completed",
})}\n`,
}),
dryMergeConflict: false,
execute: true,
forkPatchFiles: [],
upstreamChangedFiles: [],
useCodex: true,
});
assert.equal(successAudit.classification, "safe-candidate");
assert.ok(successAudit.reasonCodes.includes("NO_UPSTREAM_CHANGE"));
assert.equal(successAudit.recommendedAction, "create-candidate-branch");
const throwRoot = path.join(artifactBase, "executor-throw");
const throwAudit = buildNapcatUpstreamAudit({
artifactRoot: throwRoot,
codexExecutor: () => {
throw new Error("codex timed out");
},
dryMergeConflict: false,
execute: true,
forkPatchFiles: [],
upstreamChangedFiles: [],
useCodex: true,
});
assert.equal(throwAudit.classification, "blocked");
assert.match(
readFileSync(
path.join(throwRoot, "codex-upstream-audit.stderr.log"),
"utf8",
),
/codex timed out/,
);
let unsafeExecutorCalled = false;
assert.throws(
() =>
buildNapcatUpstreamAudit({
artifactRoot: "/tmp/not-kt",
codexExecutor: () => {
unsafeExecutorCalled = true;
return { ok: true, stderr: "", stdout: "{}\n" };
},
dryMergeConflict: false,
execute: true,
forkPatchFiles: [],
upstreamChangedFiles: [],
useCodex: true,
}),
/Unsafe NapCat Codex artifactRoot/,
);
assert.equal(unsafeExecutorCalled, false);
} finally {
rmSync(artifactBase, { force: true, recursive: true });
if (!unsafeRootExisted) {
rmSync(unsafeRoot, { force: true, recursive: true });
}
}
});
/** Verifies upstream-sync Jenkins safety and default dry-run staging. */
test("keeps the NapCat upstream Jenkins template non-mutating by default", () => {
const template = readWorkspaceText(
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile",
);
assertIncludesAll(
template,
[
"RUN_CODEX_AUDIT",
"CONFIRM_CODEX_WORKSPACE_WRITE",
"RUN_CODEX_WORKSPACE_WRITE_AUDIT",
"buildDiscarder(logRotator",
"disableConcurrentBuilds()",
"archiveArtifacts artifacts: 'artifacts/napcat-upstream-sync/**'",
],
"NapCat upstream Jenkins template",
);
assertUpstreamDefaultAuditIsDryRun(template);
assertUsesNasWorkflowRunner(template, "NapCat upstream Jenkins template");
assertExcludesAll(
template,
["git merge", "git push", "docker build", "kubectl"],
"NapCat upstream Jenkins template",
);
const unsafeTemplate = [
"stage('ktWorkflow upstream audit dry-run') {",
' pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \\',
" --execute \\",
" --use-codex",
"}",
"stage('ktWorkflow upstream audit with Codex') {",
' pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \\',
" --execute \\",
" --use-codex",
"}",
].join("\n");
assert.throws(() => assertUpstreamDefaultAuditIsDryRun(unsafeTemplate));
});
/** Verifies runtime-release Jenkins approval boundaries and stage ordering. */
test("keeps runtime image build independent from explicit API promotion", () => {
const template = readWorkspaceText(
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile",
);
assertIncludesAll(
template,
[
"UPSTREAM_RELEASE_COMMIT",
"NAPCAT_BASE_IMAGE_DIGEST",
"choice(name: 'RUNTIME_BUILD_JOB'",
"choice(name: 'API_PROMOTION_JOB'",
"CONFIRM_API_PROMOTION_TARGET",
"PROMOTE_KT_TEMPLATE_API_MAIN",
"buildDiscarder(logRotator",
"disableConcurrentBuilds()",
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-release/**'",
],
"NapCat runtime-release Jenkins template",
);
assertExcludesAll(
template,
[
"string(name: 'RUNTIME_BUILD_JOB'",
"string(name: 'API_PROMOTION_JOB'",
"git merge",
"git push",
"docker build",
"kubectl",
],
"NapCat runtime-release Jenkins template",
);
assertUsesNasWorkflowRunner(template, "NapCat runtime-release Jenkins template");
const runtimeStageStart = template.indexOf(
"stage('Runtime image build and publish')",
);
const promotionStageStart = template.indexOf("stage('API promotion')");
const pipelineEnd = template.indexOf("\n}\n\n/**", promotionStageStart);
assert.notEqual(runtimeStageStart, -1);
assert.notEqual(promotionStageStart, -1);
assert.notEqual(pipelineEnd, -1);
assert.ok(runtimeStageStart < promotionStageStart);
const runtimeStage = template.slice(runtimeStageStart, promotionStageStart);
const promotionStage = template.slice(promotionStageStart, pipelineEnd);
assert.ok(!runtimeStage.includes("CONFIRM_API_PROMOTION_TARGET"));
assertIncludesAll(
promotionStage,
[
"CONFIRM_API_PROMOTION_TARGET",
"PROMOTE_KT_TEMPLATE_API_MAIN",
"build job: params.API_PROMOTION_JOB",
],
"API promotion stage",
);
assert.ok(
promotionStage.indexOf("CONFIRM_API_PROMOTION_TARGET") <
promotionStage.indexOf("build job: params.API_PROMOTION_JOB"),
);
});
/** Verifies runtime-image Jenkins source, profile, Docker, and cleanup guardrails. */
test("keeps runtime-image Jenkins builds pinned and cleanup paths canonical", () => {
const template = readWorkspaceText(
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile",
);
assertUsesNasWorkflowRunner(template, "NapCat runtime-image Jenkins template");
assertIncludesAll(
template,
[
'ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)',
'ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")',
'case "$ARTIFACT_ROOT_REAL" in',
'"$ARTIFACTS_BASE_REAL"/*) ;;',
'ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"',
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
"NAPCAT_FORK_REF",
"NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'",
"QQBOT_NAPCAT_IMAGE_OVERRIDE",
"QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE",
"NAPCAT_BASE_IMAGE_DIGEST",
"requireRuntimeImageParameters(",
"requireImageProfileMatch(",
"-v /var/run/docker.sock:/var/run/docker.sock",
"--user 0:0",
"API_GIT_REFERENCE_HOST",
"API_GIT_REFERENCE",
'-v "$API_GIT_REFERENCE_HOST":"$API_GIT_REFERENCE":ro',
"--cap-add SYS_ADMIN",
"--security-opt apparmor=unconfined",
"--security-opt seccomp=unconfined",
"-e NAPCAT_REQUIRE_DEVICE_PROFILE=1",
'git -C "$API_ROOT" fetch "$API_GIT_REFERENCE" refs/remotes/origin/main',
'git -C "$API_ROOT" merge --ff-only FETCH_HEAD',
'"$ARTIFACT_DIR/api-root-commit.txt"',
'git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT"',
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
"KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts",
"scripts/napcat-desktop-cn-stage-build.mjs",
'--napcat-root "$NAPCAT_ROOT"',
'--napcat-base-image-digest "$NAPCAT_BASE_IMAGE_DIGEST"',
"docker build",
'docker exec "$VERIFY_NAME" sh /ci/napcat-desktop-cn/verify.sh',
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**'",
],
"NapCat runtime-image Jenkins template",
);
assert.ok(!template.includes('case "$KT_ARTIFACT_ROOT" in'));
assertExcludesAll(
template,
[
"PROMOTE_API_RUNTIME",
"CONFIRM_API_PROMOTION_TARGET",
"kubectl",
"git push",
"git merge",
"/vol1/docker/kt-codex/workspace/KT/GitHub/NapCatQQ",
],
"NapCat runtime-image Jenkins template",
);
});