test(mcp): make checkout/reviewComments tests offline (fixture-driven) (#575)
`mcp/checkout.test.ts` and `mcp/reviewComments.test.ts` previously hit live GitHub on every run via `acquireNewToken()`, requiring `GH_TOKEN` or `GITHUB_APP_ID` + `GITHUB_PRIVATE_KEY` in the env. that made them: - cred-gated — the action runtime filters `_KEY$` / `_TOKEN$` from subprocess env, so the husky pre-push hook (which runs `pnpm -r test`) blocked Pullfrog agents from pushing branches. issues #562, #563, #564, #566 all hit this exact blocker and never got their fixes pushed. - non-deterministic and slow (network round-trips for a snapshot test). both tests are really snapshot tests of pure formatters (`formatFilesWithLineNumbers`, plus `parseFilePatches` / `buildThreadBlocks` / `formatReviewThreads` for review data). the live fetches were just an inefficient way to obtain fixtures. changes: 1. extract a pure `formatReviewData({ review, threads, prFiles, ... })` from `getReviewData` in `mcp/reviewComments.ts`. `getReviewData` becomes thin orchestration: fetch + call formatter. preserves the "skip listFiles when no threads" perf optimization. 2. add `action/mcp/__fixtures__/` with checked-in JSON captures for the three fixture test cases (pullfrog/test-repo#1 listFiles, pullfrog/scratch#49 review 3485940013, pullfrog/scratch#64 review 3531000326). ~14KB total. fixtures store only the fields the formatter reads — volatile fields (sha, blob_url, etc.) are dropped. 3. rewrite both test files to load the fixtures and call the pure formatters directly. snapshot keys updated; snapshot content unchanged (verified by running existing snapshots against the refactored tests). 4. add `action/scripts/refresh-test-fixtures.ts` to re-fetch the fixtures from live GitHub on demand: `node action/scripts/refresh-test-fixtures.ts` (with creds in `.env` or env). re-run when the GitHub API response shape changes and review the snapshot diff. trade-off: a silent change to GitHub's `pulls.listFiles` / `pulls.getReview` / GraphQL `reviewThreads` response shape would no longer break this test on every push. that tradeoff is worth it: shape drift on those endpoints is rare (years between changes), and a dedicated cron that runs the refresh script and opens a PR on diff is a far better signal than a flaky cred-gated pre-push hook. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
b6e2c61d30
commit
ada5584737
+42
-52
@@ -1,8 +1,7 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken, createOctokit } from "../utils/github.ts";
|
||||
import { fetchAndFormatPrDiff } from "./checkout.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { type FormatFilesResult, formatFilesWithLineNumbers } from "./checkout.ts";
|
||||
|
||||
/**
|
||||
* parses TOC entries like "- src/math.ts → lines 7-42 · diff-<hex>" into structured data.
|
||||
@@ -22,59 +21,50 @@ function parseTocEntries(toc: string) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
// prefer explicit GH_TOKEN, fall back to acquiring one via GitHub App credentials
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
// fixture captured by action/scripts/refresh-test-fixtures.ts. running
|
||||
// the formatter against checked-in JSON keeps this test offline and
|
||||
// deterministic — re-fetch the fixture (with creds) when GitHub's
|
||||
// pulls.listFiles response shape changes, then review the snapshot diff.
|
||||
type DiffFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
files: Parameters<typeof formatFilesWithLineNumbers>[0];
|
||||
};
|
||||
|
||||
function loadFixture<T>(file: string): T {
|
||||
return JSON.parse(readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")) as T;
|
||||
}
|
||||
|
||||
describe("fetchAndFormatPrDiff", () => {
|
||||
it(
|
||||
"generates accurate TOC line numbers for pullfrog/test-repo#1",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const token = await getToken();
|
||||
const octokit = createOctokit(token);
|
||||
const ctx = {
|
||||
octokit,
|
||||
repo: {
|
||||
owner: "pullfrog",
|
||||
name: "test-repo",
|
||||
data: {} as RestEndpointMethodTypes["repos"]["get"]["response"]["data"],
|
||||
},
|
||||
} as ToolContext;
|
||||
const result = await fetchAndFormatPrDiff(ctx, 1);
|
||||
describe("formatFilesWithLineNumbers", () => {
|
||||
it("generates accurate TOC line numbers for pullfrog/test-repo#1", () => {
|
||||
const fx = loadFixture<DiffFixture>("pullfrog-test-repo-pr-1.diff.json");
|
||||
const result: FormatFilesResult = formatFilesWithLineNumbers(fx.files);
|
||||
|
||||
// verify content includes TOC at the start
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
|
||||
// parse TOC and validate every entry's line numbers against actual content
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
|
||||
// endLine should be within bounds
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
// current file starts right after previous file ends
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
// snapshot the full output for regression detection
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
);
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user