110 lines
6.8 KiB
TypeScript
110 lines
6.8 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { writeFileSync, unlinkSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
|
|
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
|
}
|
|
|
|
function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
|
|
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
});
|
|
const stdout = String(result.stdout || "");
|
|
let json: JsonRecord | null = null;
|
|
try {
|
|
json = JSON.parse(stdout) as JsonRecord;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return {
|
|
status: result.status,
|
|
stdout,
|
|
stderr: String(result.stderr || ""),
|
|
json,
|
|
};
|
|
}
|
|
|
|
function dataOf(response: JsonRecord): JsonRecord {
|
|
assertCondition(response.ok === true, "CLI command should succeed", response);
|
|
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response);
|
|
return response.data as JsonRecord;
|
|
}
|
|
|
|
export function runGhCliPrContract(): JsonRecord {
|
|
const help = runCli(["gh", "help"]);
|
|
assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout });
|
|
const helpData = dataOf(help.json ?? {});
|
|
const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : [];
|
|
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
|
|
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
|
|
|
|
const title = "contract pr create";
|
|
const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`);
|
|
writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8");
|
|
try {
|
|
const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]);
|
|
assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout });
|
|
const createData = dataOf(createDryRun.json ?? {});
|
|
assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData);
|
|
assertCondition(createData.planned === true, "dry-run create must set planned=true", createData);
|
|
assertCondition(createData.repo === "pikasTech/unidesk", "dry-run create should preserve repo", createData);
|
|
assertCondition(createData.base === "master", "dry-run create should preserve base", createData);
|
|
assertCondition(createData.head === "feature/pr-contract", "dry-run create should preserve head", createData);
|
|
assertCondition(createData.draft === true, "dry-run create should preserve draft", createData);
|
|
assertCondition(Number(createData.bodyChars ?? 0) > 0, "dry-run create should expose bodyChars", createData);
|
|
assertCondition(Array.isArray(createData.bodyPreviewLines), "dry-run create should expose bodyPreviewLines", createData);
|
|
assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData);
|
|
assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData);
|
|
|
|
const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]);
|
|
assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout });
|
|
const commentData = dataOf(commentDryRun.json ?? {});
|
|
assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData);
|
|
assertCondition(commentData.planned === true, "dry-run comment must set planned=true", commentData);
|
|
assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData);
|
|
assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData);
|
|
|
|
const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
|
|
assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout });
|
|
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
|
|
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
|
|
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
|
|
|
|
const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]);
|
|
assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout });
|
|
const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined;
|
|
assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {});
|
|
assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {});
|
|
|
|
const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]);
|
|
assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout });
|
|
const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined;
|
|
assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {});
|
|
assertCondition(unknownOptionData?.runnerDisposition === "business-failed", "unknown option should classify as business-failed", unknownOptionData ?? {});
|
|
} finally {
|
|
unlinkSync(bodyFile);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
checks: [
|
|
"gh help lists pr create/comment",
|
|
"pr create dry-run exposes planned operation",
|
|
"pr comment dry-run preserves markdown text",
|
|
"pr merge is blocked",
|
|
"pr create validation failures are structured",
|
|
"unknown gh options are structured",
|
|
],
|
|
};
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const result = runGhCliPrContract();
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
}
|