pi-mono/packages/coding-agent/test/tools-manager.test.ts
Petr Houška c8ec752cf9 fix(coding-agent): skip fd/rg downloads on unsupported architectures
Only x64 and arm64 map to prebuilt release archives. ARM32 and ia32 previously
fell back to x86-64 binaries; now pi warns and recommends the system package manager.

fixes #11209
2026-10-23 19:48:59 +02:00

175 lines
5.3 KiB
TypeScript

import type * as ChildProcess from "node:child_process";
import type * as Fs from "node:fs";
import type * as Os from "node:os";
import { arch, platform } from "os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureTool, getLatestVersion, type ToolStatus } from "../src/utils/tools-manager.ts";
const originalOffline = process.env.PI_OFFLINE;
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof Fs>();
return {
...actual,
existsSync: vi.fn(() => false),
};
});
vi.mock("os", async (importOriginal) => {
const actual = await importOriginal<typeof Os>();
return {
...actual,
arch: vi.fn(() => "x64"),
platform: vi.fn(() => "linux"),
};
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>();
return {
...actual,
spawnSync: vi.fn(() => ({ error: new Error("not found") })),
};
});
afterEach(() => {
if (originalOffline === undefined) delete process.env.PI_OFFLINE;
else process.env.PI_OFFLINE = originalOffline;
vi.unstubAllGlobals();
vi.mocked(arch).mockReturnValue("x64");
vi.mocked(platform).mockReturnValue("linux");
});
function redirectResponse(location: string): Response {
return new Response(null, { status: 302, headers: { location } });
}
describe("getLatestVersion", () => {
it("resolves the version from the release page redirect", async () => {
const fetchMock = vi.fn(async () => redirectResponse("https://github.com/sharkdp/fd/releases/tag/v10.4.2"));
vi.stubGlobal("fetch", fetchMock);
await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2");
expect(fetchMock).toHaveBeenCalledWith(
"https://github.com/sharkdp/fd/releases/latest",
expect.objectContaining({ redirect: "manual" }),
);
});
it("keeps tags without a v prefix intact", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => redirectResponse("https://github.com/BurntSushi/ripgrep/releases/tag/15.2.0")),
);
await expect(getLatestVersion("BurntSushi/ripgrep")).resolves.toBe("15.2.0");
});
it("resolves relative redirect targets", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => redirectResponse("/sharkdp/fd/releases/tag/v10.4.2")),
);
await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2");
});
it("discards the redirect response body", async () => {
const response = new Response("<html></html>", {
status: 302,
headers: { location: "https://github.com/sharkdp/fd/releases/tag/v10.4.2" },
});
vi.stubGlobal(
"fetch",
vi.fn(async () => response),
);
await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2");
expect(response.bodyUsed).toBe(true);
});
it("fails clearly when the endpoint does not redirect", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("not found", { status: 404 })),
);
await expect(getLatestVersion("sharkdp/fd")).rejects.toThrow(
"Failed to resolve latest sharkdp/fd release: HTTP 404 without redirect",
);
});
it("fails clearly when the redirect does not point at a release tag", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => redirectResponse("https://github.com/login")),
);
await expect(getLatestVersion("sharkdp/fd")).rejects.toThrow(
"Failed to resolve latest sharkdp/fd release: unexpected redirect to https://github.com/login",
);
});
});
describe("ensureTool", () => {
it("reports status through a callback without writing to the console", async () => {
process.env.PI_OFFLINE = "1";
const statuses: ToolStatus[] = [];
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await ensureTool("fd", (status) => statuses.push(status));
expect(result).toBeUndefined();
expect(statuses).toEqual([
{
type: "warning",
message: "fd not found. Offline mode enabled, skipping download.",
},
]);
expect(consoleLog).not.toHaveBeenCalled();
consoleLog.mockRestore();
});
it("surfaces the error cause chain when a download fails", async () => {
delete process.env.PI_OFFLINE;
const cause = new Error("connect ETIMEDOUT 140.82.113.3:443");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("fetch failed", { cause });
}),
);
const statuses: ToolStatus[] = [];
const result = await ensureTool("fd", (status) => statuses.push(status));
expect(result).toBeUndefined();
expect(statuses).toEqual([
{ type: "info", message: "fd not found. Downloading..." },
{
type: "warning",
message: "Failed to download fd: fetch failed: connect ETIMEDOUT 140.82.113.3:443",
},
]);
});
// #11209: ARM32 previously fell back to x86-64 release archives.
it.each(["arm", "ia32"])("does not download x86-64 binaries on %s", async (architecture) => {
delete process.env.PI_OFFLINE;
vi.mocked(arch).mockReturnValue(architecture as NodeJS.Architecture);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const statuses: ToolStatus[] = [];
const result = await ensureTool("rg", (status) => statuses.push(status));
expect(result).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
expect(statuses).toEqual([
{
type: "warning",
message: `ripgrep not found. Automatic download is not supported on linux/${architecture}. Install ripgrep with your system package manager.`,
},
]);
});
});