Files
pikasTech-unidesk/scripts/gh-cli-pr-files-contract-test.ts
T
2026-05-29 09:36:50 +00:00

247 lines
13 KiB
TypeScript

import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
type JsonRecord = Record<string, unknown>;
interface MockRequest {
method: string;
url: string;
body: string;
}
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runBun(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", 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,
});
});
});
}
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return runBun(["scripts/cli.ts", ...args], env);
}
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));
}
function prFixture(): JsonRecord {
return {
id: 4200,
number: 42,
title: "CLI summary fixture",
body: "fixture body",
state: "open",
html_url: "https://github.example/pikasTech/unidesk/pull/42",
draft: false,
user: { login: "runner" },
head: { ref: "feature/pr-files", sha: "abc123" },
base: { ref: "master", sha: "def456" },
additions: 12,
deletions: 3,
changed_files: 2,
commits: 1,
created_at: "2026-05-23T00:00:00Z",
updated_at: "2026-05-23T00:10:00Z",
};
}
function prFilesFixture(): JsonRecord[] {
return [
{
sha: "aaa",
filename: "scripts/src/gh.ts",
status: "modified",
additions: 10,
deletions: 2,
changes: 12,
blob_url: "https://github.example/blob/scripts/src/gh.ts",
raw_url: "https://github.example/raw/scripts/src/gh.ts",
contents_url: "https://api.github.example/contents/scripts/src/gh.ts",
patch: "@@ raw diff must not be returned @@",
},
{
sha: "bbb",
filename: "scripts/gh-cli-pr-files-contract-test.ts",
status: "added",
additions: 2,
deletions: 1,
changes: 3,
blob_url: "https://github.example/blob/scripts/gh-cli-pr-files-contract-test.ts",
raw_url: "https://github.example/raw/scripts/gh-cli-pr-files-contract-test.ts",
contents_url: "https://api.github.example/contents/scripts/gh-cli-pr-files-contract-test.ts",
patch: "@@ raw diff must not be returned either @@",
},
];
}
async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> {
const requests: MockRequest[] = [];
const server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42") {
sendJson(res, 200, prFixture());
return;
}
if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42/files") {
const perPage = Number(url.searchParams.get("per_page") ?? "30");
const page = Number(url.searchParams.get("page") ?? "1");
const offset = (page - 1) * perPage;
sendJson(res, 200, prFilesFixture().slice(offset, offset + perPage));
return;
}
sendJson(res, 404, { message: `unexpected ${req.method} ${req.url}` });
});
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;
}
function failedDataOf(response: JsonRecord): JsonRecord {
assertCondition(response.ok === false, "CLI command should fail", response);
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response);
return response.data as JsonRecord;
}
export async function runGhCliPrFilesContract(): 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)) : [];
const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : [];
assertCondition(usage.some((line) => line.includes("gh pr files <number>")), "help should document gh pr files", { usage });
assertCondition(usage.some((line) => line.includes("gh pr diff <number> --stat")), "help should document gh pr diff --stat", { usage });
assertCondition(notes.some((line) => line.includes("PR files is the canonical compact changed-file/stat summary")), "help should document PR files disclosure boundary", { notes });
const mock = await startMockGitHub();
const env = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
const checks: string[] = ["gh help documents pr files and pr diff --stat"];
try {
mock.requests.length = 0;
const files = await runCli(["gh", "pr", "files", "42", "--repo", "pikasTech/unidesk", "--limit", "1"], env);
assertCondition(files.status === 0, "gh pr files should succeed", files.json ?? { stdout: files.stdout, stderr: files.stderr });
const filesData = dataOf(files.json ?? {});
assertCondition(filesData.command === "pr files", "command name should be pr files", filesData);
assertCondition(filesData.rawDiffIncluded === false, "rawDiffIncluded must be false", filesData);
const summary = filesData.summary as JsonRecord;
assertCondition(summary.files === 2, "summary must include total changed files", summary);
assertCondition(summary.additions === 12, "summary additions should come from PR REST", summary);
assertCondition(summary.deletions === 3, "summary deletions should come from PR REST", summary);
assertCondition(summary.changes === 15, "summary changes should include additions plus deletions", summary);
assertCondition(filesData.filesReturned === 1, "limit 1 should return one file", filesData);
const fileRows = filesData.files as JsonRecord[];
assertCondition(Array.isArray(fileRows) && fileRows.length === 1, "files list should be bounded", filesData);
assertCondition(fileRows[0]?.filename === "scripts/src/gh.ts", "first file filename mismatch", fileRows[0]);
assertCondition(fileRows[0]?.patch === undefined, "raw patch must not be emitted", fileRows[0]);
assertCondition(fileRows[0]?.raw_url === undefined, "raw_url field casing must not leak", fileRows[0]);
const truncation = filesData.truncation as JsonRecord;
assertCondition(truncation.truncated === true, "limit 1 should mark truncation", truncation);
assertCondition(truncation.totalFiles === 2, "truncation should expose totalFiles", truncation);
const next = filesData.next as JsonRecord;
assertCondition(String(next.command).includes("--limit 2"), "next command should request the bounded full file count", next);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "files endpoint should use bounded per_page", mock.requests);
checks.push("gh pr files returns bounded REST file/stat JSON without raw patches");
mock.requests.length = 0;
const filesOptionsFirst = await runCli(["gh", "pr", "files", "--repo", "pikasTech/unidesk", "--limit", "1", "42"], env);
assertCondition(filesOptionsFirst.status === 0, "gh pr files should accept PR number after options", filesOptionsFirst.json ?? { stdout: filesOptionsFirst.stdout, stderr: filesOptionsFirst.stderr });
const filesOptionsFirstData = dataOf(filesOptionsFirst.json ?? {});
assertCondition(filesOptionsFirstData.command === "pr files" && filesOptionsFirstData.filesReturned === 1, "options-first pr files should return compact file summary", filesOptionsFirstData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "options-first pr files should query the requested PR number", mock.requests);
checks.push("gh pr files accepts the PR number after options");
mock.requests.length = 0;
const stat = await runCli(["gh", "pr", "diff", "42", "--stat", "--repo", "pikasTech/unidesk", "--limit", "2"], env);
assertCondition(stat.status === 0, "gh pr diff --stat should succeed", stat.json ?? { stdout: stat.stdout, stderr: stat.stderr });
const statData = dataOf(stat.json ?? {});
assertCondition(statData.command === "pr diff --stat", "diff --stat should report alias command name", statData);
assertCondition(statData.rawDiffIncluded === false, "diff --stat must not include raw diff", statData);
assertCondition(statData.filesReturned === 2, "diff --stat should return requested files", statData);
const statRows = statData.files as JsonRecord[];
assertCondition(statRows.every((file) => file.patch === undefined), "diff --stat file rows must not include patch", statRows);
checks.push("gh pr diff --stat is a compact summary alias");
mock.requests.length = 0;
const statOptionsFirst = await runCli(["gh", "pr", "diff", "--repo", "pikasTech/unidesk", "--stat", "--limit", "2", "42"], env);
assertCondition(statOptionsFirst.status === 0, "gh pr diff --stat should accept PR number after options", statOptionsFirst.json ?? { stdout: statOptionsFirst.stdout, stderr: statOptionsFirst.stderr });
const statOptionsFirstData = dataOf(statOptionsFirst.json ?? {});
assertCondition(statOptionsFirstData.command === "pr diff --stat" && statOptionsFirstData.filesReturned === 2, "options-first pr diff --stat should return compact file summary", statOptionsFirstData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=2&page=1"), "options-first pr diff --stat should query the requested PR number", mock.requests);
checks.push("gh pr diff --stat accepts the PR number after options");
const rawDiff = await runCli(["gh", "pr", "diff", "42", "--repo", "pikasTech/unidesk"], env);
assertCondition(rawDiff.status !== 0, "gh pr diff without --stat should fail closed", rawDiff.json ?? { stdout: rawDiff.stdout, stderr: rawDiff.stderr });
const rawData = failedDataOf(rawDiff.json ?? {});
assertCondition(rawData.degradedReason === "unsupported-command", "raw diff should fail as unsupported-command", rawData);
assertCondition(rawData.rawDiffIncluded === false, "raw diff failure should state rawDiffIncluded=false", rawData);
checks.push("gh pr diff without --stat fails closed without raw diff output");
return { ok: true, checks };
} finally {
await mock.close();
}
}
if (import.meta.main) {
runGhCliPrFilesContract()
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exitCode = 1;
});
}