From 0b91b6198dc00e858d9209b20380eff1f58e7b4b Mon Sep 17 00:00:00 2001 From: sanmaxdev Date: Thu, 25 Jun 2026 10:33:17 +0000 Subject: [PATCH] test: mock create-pr git subprocess calls --- tests/create-pr.test.ts | 125 +++++++++++++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 940975f..3aca416 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -1,20 +1,82 @@ -import { execSync } from "node:child_process"; +import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { - buildPullRequestBody, - buildPullRequestTitle, - collectAdvisoryIdsForPackage, - defaultFixBranchName, - findingsMeetFailOnThreshold, - selectAvailableBranchName, - stageDependencyFilesOnly, +import { jest } from "@jest/globals"; +import type { + buildPullRequestBody as buildPullRequestBodyType, + buildPullRequestTitle as buildPullRequestTitleType, + collectAdvisoryIdsForPackage as collectAdvisoryIdsForPackageType, + defaultFixBranchName as defaultFixBranchNameType, + findingsMeetFailOnThreshold as findingsMeetFailOnThresholdType, + selectAvailableBranchName as selectAvailableBranchNameType, + stageDependencyFilesOnly as stageDependencyFilesOnlyType, } from "../src/utils/create-pr.js"; import type { Finding, OsvVuln } from "../src/types.js"; import { validateOptions } from "../src/cli/validate.js"; import { parseArgs } from "../src/cli/args.js"; +const spawnMock = jest.fn(); +const unstableMockModule = (jest as unknown as { + unstable_mockModule: (moduleName: string, moduleFactory: () => unknown) => typeof jest; +}).unstable_mockModule; + +unstableMockModule("node:child_process", () => ({ + spawn: spawnMock, +})); + +let buildPullRequestBody: typeof buildPullRequestBodyType; +let buildPullRequestTitle: typeof buildPullRequestTitleType; +let collectAdvisoryIdsForPackage: typeof collectAdvisoryIdsForPackageType; +let defaultFixBranchName: typeof defaultFixBranchNameType; +let findingsMeetFailOnThreshold: typeof findingsMeetFailOnThresholdType; +let selectAvailableBranchName: typeof selectAvailableBranchNameType; +let stageDependencyFilesOnly: typeof stageDependencyFilesOnlyType; + +type MockCommandResult = { + stdout?: string; + stderr?: string; + status?: number | null; + error?: Error; +}; + +function queueCommandResults(results: MockCommandResult[]): void { + const queue = [...results]; + spawnMock.mockImplementation(() => { + const result = queue.shift() ?? { status: 0 }; + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + + process.nextTick(() => { + if (result.error) { + child.emit("error", result.error); + return; + } + if (result.stdout) child.stdout.emit("data", result.stdout); + if (result.stderr) child.stderr.emit("data", result.stderr); + child.emit("close", result.status ?? 0); + }); + + return child; + }); +} + +beforeAll(async () => { + const mod = await import("../src/utils/create-pr.js"); + buildPullRequestBody = mod.buildPullRequestBody; + buildPullRequestTitle = mod.buildPullRequestTitle; + collectAdvisoryIdsForPackage = mod.collectAdvisoryIdsForPackage; + defaultFixBranchName = mod.defaultFixBranchName; + findingsMeetFailOnThreshold = mod.findingsMeetFailOnThreshold; + selectAvailableBranchName = mod.selectAvailableBranchName; + stageDependencyFilesOnly = mod.stageDependencyFilesOnly; +}); + +beforeEach(() => { + spawnMock.mockReset(); +}); + function createFinding(overrides?: Partial): Finding { const vuln: OsvVuln = { id: "GHSA-abc", @@ -105,28 +167,47 @@ describe("create-pr helpers", () => { }); it("adds numeric suffix when branch already exists", async () => { - const branch = await selectAvailableBranchName(process.cwd(), "cve-lite/fix-2026-06-02"); - expect(branch).toMatch(/^cve-lite\/fix-2026-06-02(?:-\d+)?$/); + queueCommandResults([{ status: 0 }, { status: 1 }]); + + await expect(selectAvailableBranchName("/repo", "cve-lite/fix-2026-06-02")).resolves.toBe( + "cve-lite/fix-2026-06-02-2", + ); + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + "git", + ["rev-parse", "--verify", "cve-lite/fix-2026-06-02"], + expect.objectContaining({ cwd: "/repo" }), + ); + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + "git", + ["rev-parse", "--verify", "cve-lite/fix-2026-06-02-2"], + expect.objectContaining({ cwd: "/repo" }), + ); }); - it("stages only existing dependency files without failing on missing lockfiles", async () => { + it("stages only existing dependency files without requiring a git repository", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-create-pr-")); try { - execSync("git init", { cwd: tmpDir, stdio: "ignore" }); - execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: "ignore" }); - execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: "ignore" }); - fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test"}\n'); fs.writeFileSync(path.join(tmpDir, "package-lock.json"), '{"lockfileVersion":3}\n'); - execSync("git add package.json package-lock.json", { cwd: tmpDir, stdio: "ignore" }); - execSync('git commit -m "init"', { cwd: tmpDir, stdio: "ignore" }); - - fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test","version":"1.0.1"}\n'); + queueCommandResults([{ status: 0 }, { status: 0 }]); await expect(stageDependencyFilesOnly(tmpDir)).resolves.toBeUndefined(); - const staged = execSync("git diff --cached --name-only", { cwd: tmpDir, encoding: "utf8" }); - expect(staged.trim().split("\n")).toEqual(["package.json"]); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + "git", + ["add", "--", "package.json"], + expect.objectContaining({ cwd: tmpDir }), + ); + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + "git", + ["add", "--", "package-lock.json"], + expect.objectContaining({ cwd: tmpDir }), + ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }