Files
pikasTech-unidesk/scripts/host-codex-commander-approval-notification-contract-test.ts
T
2026-05-23 19:22:07 +08:00

165 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { commanderApprovalProxyFailureSummary } from "../src/components/microservices/host-codex-commander/src/approval-notification";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
return value as JsonRecord;
}
function runCli(args: string[], expectStatus: number, extraEnv: Record<string, string> = {}): JsonRecord {
const result = spawnSync(process.execPath, ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
...extraEnv,
},
maxBuffer: 4 * 1024 * 1024,
});
assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, {
status: result.status,
stdout: result.stdout.slice(-2000),
stderr: result.stderr.slice(-2000),
});
assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`);
return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope");
}
function dataOf(envelope: JsonRecord): JsonRecord {
return asRecord(envelope.data, "data");
}
function charLength(value: string): number {
return Array.from(value).length;
}
function assertPlainApprovalMessage(message: string): void {
assertCondition(charLength(message) <= 200, "approval notification draft must be <=200 chars", { message, chars: charLength(message) });
assertCondition(!/[`*_#[\]|]/u.test(message), "approval notification draft must not contain Markdown syntax", { message });
assertCondition(!message.includes("\n"), "approval notification draft must be one paragraph", { message });
}
const approval = dataOf(runCli([
"commander",
"approval",
"request",
"--action",
"code-queue-task-interrupt",
"--task-id",
"stale-active-118",
"--reason",
"stale-active 恢复需要 interrupttoken=ghp_1234567890abcdef;不要使用 `local` 路径",
"--dry-run",
], 0, {
PATH: "/usr/bin:/bin",
}));
assertCondition(approval.ok === true, "approval dry-run must succeed without local powershell", approval);
assertCondition(approval.mutation === false, "approval dry-run must be non-mutating", approval);
assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval dry-run must redact secret-like reason", approval);
const claudeqq = asRecord(approval.claudeqq, "claudeqq");
assertCondition(claudeqq.mutation === false, "ClaudeQQ dry-run must not mutate", claudeqq);
assertCondition(claudeqq.sendImplemented === false, "commander skeleton must not claim send implementation", claudeqq);
assertCondition(claudeqq.dryRunNoClaudeQqSend === true, "dry-run must explicitly report no ClaudeQQ send", claudeqq);
const draft = asRecord(claudeqq.notificationDraft, "claudeqq.notificationDraft");
assertCondition(draft.format === "plain-text", "approval draft must be plain text", draft);
assertCondition(draft.markdownAllowed === false, "approval draft must forbid Markdown", draft);
assertCondition(draft.containsMarkdownSyntax === false, "approval draft must report no Markdown syntax", draft);
assertPlainApprovalMessage(String(draft.message));
const path = asRecord(claudeqq.notificationPath, "claudeqq.notificationPath");
assertCondition(path.error === "notification-path-unavailable", "dry-run must expose notification-path-unavailable blocker", path);
assertCondition(path.servicePath === "/api/microservices/claudeqq/proxy/api/push/text", "service path must be backend-core ClaudeQQ proxy", path);
assertCondition(String(path.backendCoreProxyCommand).includes("bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST"), "backend-core proxy command must be returned", path);
assertCondition(!String(path.backendCoreProxyCommand).includes("powershell"), "backend-core proxy command must not use local powershell", path);
assertCondition(!String(path.backendCoreProxyCommand).includes(".agents/skills/claudeqq"), "backend-core proxy command must not use local skill path", path);
assertCondition(path.timeoutMs === 15000, "proxy path must disclose timeout", path);
const failure = commanderApprovalProxyFailureSummary({
ok: false,
status: 503,
error: "microservice proxy task failed",
stderrTail: "token=ghp_1234567890abcdef powershell.exe failed",
});
assertCondition(failure.ok === false, "proxy failure summary must be failing", failure);
assertCondition(failure.error === "notification-proxy-failed", "proxy failure summary must be structured", failure);
assertCondition(failure.degradedReason === "microservice-proxy-failed", "proxy failure degraded reason must be microservice-proxy-failed", failure);
assertCondition(asRecord(failure.rollback, "rollback").issueUpdateRolledBack === false, "proxy failure must not imply issue rollback", failure);
assertCondition(!JSON.stringify(failure).includes("ghp_1234567890abcdef"), "proxy failure summary must redact secrets", failure);
const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-brief-notify-"));
try {
const bodyPath = join(tmp, "brief.md");
writeFileSync(bodyPath, [
"# 指挥简报",
"",
"## 常驻观察与长期建议",
"",
"- 保持监督。",
"",
"## 更新 2026-05-23 10:00 北京时间",
"",
"- 新增高风险审批路径 dry-run 预览。",
"",
].join("\n"), "utf8");
const ghDryRun = dataOf(runCli([
"gh",
"issue",
"edit",
"24",
"--body-file",
bodyPath,
"--notify-claudeqq-brief-diff",
"--dry-run",
], 0, {
UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL: "http://127.0.0.1:9082",
}));
const notification = asRecord(ghDryRun.commanderBriefNotification, "commanderBriefNotification");
const ghClaudeqq = asRecord(notification.claudeqq, "commanderBriefNotification.claudeqq");
assertCondition(ghClaudeqq.wouldSend === false, "dry-run helper must not allow non-proxy ClaudeQQ path", ghClaudeqq);
assertCondition(ghClaudeqq.blockedReason === "notification-path-unavailable", "dry-run helper must structure non-proxy blocker", ghClaudeqq);
assertCondition(String(ghClaudeqq.recommendedCommand).includes("microservice proxy claudeqq"), "dry-run helper must recommend repo-owned proxy command", ghClaudeqq);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const hostDoc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
const supervisionDoc = readFileSync("docs/reference/code-queue-supervision.md", "utf8");
for (const snippet of [
"notification-path-unavailable",
"microservice proxy claudeqq /api/push/text",
"200 字以内中文纯文本",
]) {
assertCondition(hostDoc.includes(snippet), `host commander doc missing snippet: ${snippet}`);
}
for (const snippet of [
"不超过 200 个中文字符",
"不使用 Markdown 语法",
"发送失败只记录到 #24 或对应 blocker issue",
]) {
assertCondition(supervisionDoc.includes(snippet), `code queue supervision doc missing snippet: ${snippet}`);
}
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"commander approval dry-run survives without local powershell or ClaudeQQ skill server",
"dry-run generates a <=200 char Chinese plain-text ClaudeQQ draft and does not send",
"dry-run exposes notification-path-unavailable plus backend-core microservice proxy command",
"microservice proxy failure summary is structured, redacted, and best-effort",
"legacy gh issue edit notify dry-run rejects non-proxy ClaudeQQ base URLs",
"reference docs state the high-risk ClaudeQQ approval notification path",
],
}, null, 2)}\n`);