Files
pikasTech-unidesk/scripts/gh-cli-pr-contract-test.ts
T
2026-05-20 12:52:58 +00:00

275 lines
16 KiB
TypeScript

import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
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[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
env: { ...process.env, ...env },
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (status) => {
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
let json: JsonRecord | null = null;
try {
json = JSON.parse(stdout) as JsonRecord;
} catch {
json = null;
}
resolve({
status,
stdout,
stderr: Buffer.concat(stderrChunks).toString("utf8"),
json,
});
});
});
}
interface MockRequest {
method: string;
url: string;
body: string;
}
function collectBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
}
async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> {
const requests: MockRequest[] = [];
const pullRequest = {
id: 4200,
number: 42,
title: "contract PR",
body: "PR body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/pull/42",
draft: false,
user: { login: "runner" },
head: { ref: "feature/pr-contract", sha: "head-sha" },
base: { ref: "master", sha: "base-sha" },
created_at: "2026-05-20T04:00:00Z",
updated_at: "2026-05-20T05:00:00Z",
};
const server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk") {
sendJson(res, 200, { id: 1, full_name: "pikasTech/unidesk", private: true, default_branch: "master", permissions: { pull: true, push: true } });
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") {
sendJson(res, 200, [pullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
sendJson(res, 200, pullRequest);
return;
}
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { ...pullRequest, title: String(parsed.title ?? pullRequest.title), body: String(parsed.body ?? pullRequest.body), state: String(parsed.state ?? pullRequest.state), updated_at: "2026-05-20T06:00:00Z" });
return;
}
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/42/comments") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 201, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:10:00Z" });
return;
}
if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
res.statusCode = 204;
res.end();
return;
}
sendJson(res, 404, { message: "not found" });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "mock server should expose port");
return {
baseUrl: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
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 async function runGhCliPrContract(): Promise<JsonRecord> {
const help = await 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 mock = await startMockGitHub();
const env = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
try {
const list = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--limit", "4"], env);
assertCondition(list.status === 0, "pr list should succeed through REST", list.json ?? { stdout: list.stdout });
const listData = dataOf(list.json ?? {});
const pullRequests = listData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData);
assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(view.status === 0, "pr view should succeed through REST", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {});
const pullRequest = viewData.pullRequest as JsonRecord;
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData);
const selected = viewData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr view --json should select fields", viewData);
} finally {
await mock.close();
}
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 = await 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 = await 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 mock2 = await startMockGitHub();
const env2 = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock2.baseUrl,
};
try {
const updateReplace = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--title", "updated"], env2);
assertCondition(updateReplace.status === 0, "pr update replace should succeed", updateReplace.json ?? { stdout: updateReplace.stdout });
const updateReplaceData = dataOf(updateReplace.json ?? {});
assertCondition(updateReplaceData.command === "pr update" && updateReplaceData.mode === "replace", "pr update replace should report mode", updateReplaceData);
const updateAppend = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", bodyFile, "--dry-run"], env2);
assertCondition(updateAppend.status === 0, "pr update append dry-run should succeed", updateAppend.json ?? { stdout: updateAppend.stdout });
const updateAppendData = dataOf(updateAppend.json ?? {});
const finalBody = updateAppendData.finalBody as JsonRecord;
assertCondition(updateAppendData.mode === "append", "pr append mode should be explicit", updateAppendData);
assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData);
const closePr = await runCli(["gh", "pr", "close", "42", "--repo", "pikasTech/unidesk"], env2);
assertCondition(closePr.status === 0, "pr close should succeed", closePr.json ?? { stdout: closePr.stdout });
const closeData = dataOf(closePr.json ?? {});
assertCondition(closeData.command === "pr close", "pr close command should be explicit", closeData);
const reopenPr = await runCli(["gh", "pr", "reopen", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env2);
assertCondition(reopenPr.status === 0, "pr reopen dry-run should succeed", reopenPr.json ?? { stdout: reopenPr.stdout });
const reopenData = dataOf(reopenPr.json ?? {});
assertCondition(reopenData.command === "pr reopen" && reopenData.dryRun === true, "pr reopen dry-run should be explicit", reopenData);
const commentCreate = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile], env2);
assertCondition(commentCreate.status === 0, "pr comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout });
const commentCreateData = dataOf(commentCreate.json ?? {});
assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData);
const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2);
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {});
assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData);
} finally {
await mock2.close();
}
const mergeBlocked = await 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 deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]);
assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout });
const deleteData = deleteBlocked.json?.data as JsonRecord | undefined;
assertCondition(deleteData?.degradedReason === "unsupported-command", "pr delete should be unsupported-command", deleteData ?? {});
const createMissingBody = await 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 = await 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 list/view work through REST with token and no gh binary dependency",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update replace/append and close/reopen are available",
"pr comment create/delete follows CRUD shape",
"pr merge is blocked",
"pr hard delete is blocked",
"pr create validation failures are structured",
"unknown gh options are structured",
],
};
}
if (import.meta.main) {
const result = await runGhCliPrContract();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}