Simplify Python runtime probing and tests

This commit is contained in:
William Woodruff
2026-09-03 15:35:14 -04:00
parent 70e5842acc
commit ce013a24a4
3 changed files with 103 additions and 232 deletions
+79 -178
View File
@@ -1,33 +1,29 @@
import * as path from "node:path";
import type * as exec from "@actions/exec";
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, it, jest } from "@jest/globals";
import { createSetupInputs } from "../helpers/setup-inputs";
const mockGetExecOutput = jest.fn<typeof exec.getExecOutput>();
const mockExecFile =
jest.fn<
(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>
>();
const mockDebug = jest.fn();
const originalPlatform = process.platform;
const inputs = createSetupInputs({
activateEnvironment: true,
pythonVersion: "3.15t",
});
jest.unstable_mockModule("@actions/core", () => ({
debug: mockDebug,
isDebug: jest.fn(() => false),
}));
jest.unstable_mockModule("@actions/exec", () => ({
getExecOutput: mockGetExecOutput,
jest.unstable_mockModule("@actions/core", () => ({ debug: mockDebug }));
jest.unstable_mockModule("node:child_process", () => ({
// execFile's custom promisifier returns both stdout and stderr.
execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }),
}));
const { getPythonRuntimeId } = await import("../../src/utils/python-runtime");
function mockRuntime(overrides: Record<string, unknown> = {}) {
mockGetExecOutput.mockResolvedValue({
exitCode: 0,
mockExecFile.mockResolvedValue({
stderr: "",
stdout: `${JSON.stringify({
freethreaded: false,
@@ -40,171 +36,76 @@ function mockRuntime(overrides: Record<string, unknown> = {}) {
}
beforeEach(() => {
mockGetExecOutput.mockReset();
mockExecFile.mockReset();
mockRuntime();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
});
describe("getPythonRuntimeId", () => {
it("does not query Python when environment activation is disabled", async () => {
const inputs = createSetupInputs({ pythonVersion: "3.15t" });
it("does not query Python without environment activation", async () => {
expect(
await getPythonRuntimeId({ ...inputs, activateEnvironment: false }),
).toBe("");
expect(mockExecFile).not.toHaveBeenCalled();
});
expect(await getPythonRuntimeId(inputs)).toBe("");
expect(mockGetExecOutput).not.toHaveBeenCalled();
it.each([
["3.13.1", false, "cpython-3.13.1"],
["3.15.0a1", false, "cpython-3.15.0a1"],
["3.15.0b2", false, "cpython-3.15.0b2"],
["3.15.0rc1", false, "cpython-3.15.0rc1"],
["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"],
["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"],
["3.15.0", true, "cpython-3.15.0-freethreaded"],
])("formats CPython %s, free-threading=%s", async (version, free, expected) => {
mockRuntime({ freethreaded: free, pythonVersion: version });
expect(await getPythonRuntimeId(inputs)).toBe(expected);
});
it.each([
["pypy", [7, 3, 23, "final", 0], "pypy-7.3.23"],
["pypy", [7, 3, 24, "final", 0], "pypy-7.3.24"],
["pypy", [7, 3, 24, "alpha", 1], "pypy-7.3.24a1"],
["pypy", [7, 3, 24, "beta", 2], "pypy-7.3.24b2"],
["pypy", [7, 3, 24, "candidate", 3], "pypy-7.3.24rc3"],
["graalpy", [25, 0, 0, "final", 0], "graalpy-25.0.0"],
["pypy", [7, 3, 24, "unknown", 0], ""],
])("formats %s implementation version %j", async (name, version, expected) => {
mockRuntime({
implementation: name,
implementationVersion: version,
pythonVersion: "3.11.15",
});
it("identifies the resolved CPython version rather than the request", async () => {
const inputs = createSetupInputs({
activateEnvironment: true,
pythonVersion: "3.13",
});
expect(await getPythonRuntimeId(inputs)).toBe("cpython-3.13.1");
expect(inputs.pythonVersion).toBe("3.13");
});
it.each([
["3.15.0a1", false, "cpython-3.15.0a1"],
["3.15.0b2", false, "cpython-3.15.0b2"],
["3.15.0rc1", false, "cpython-3.15.0rc1"],
["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"],
["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"],
["3.15.0", true, "cpython-3.15.0-freethreaded"],
])(
"preserves version %s and free-threading=%s as %s",
async (pythonVersion, freethreaded, expected) => {
mockRuntime({ freethreaded, pythonVersion });
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe(expected);
},
);
it("derives free-threading from the interpreter rather than the input", async () => {
const inputs = createSetupInputs({
activateEnvironment: true,
pythonVersion: "3.13.1t",
});
expect(await getPythonRuntimeId(inputs)).toBe("cpython-3.13.1");
});
it.each([
[[7, 3, 23, "final", 0], "pypy-7.3.23-python-3.11.15"],
[[7, 3, 24, "final", 0], "pypy-7.3.24-python-3.11.15"],
[[7, 3, 24, "alpha", 1], "pypy-7.3.24a1-python-3.11.15"],
[[7, 3, 24, "beta", 2], "pypy-7.3.24b2-python-3.11.15"],
[[7, 3, 24, "candidate", 3], "pypy-7.3.24rc3-python-3.11.15"],
])("includes PyPy implementation version %j", async (version, expected) => {
mockRuntime({
implementation: "pypy",
implementationVersion: version,
pythonVersion: "3.11.15",
});
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe(expected);
});
it("includes the name and version of other Python implementations", async () => {
mockRuntime({
implementation: "graalpy",
implementationVersion: [25, 0, 0, "final", 0],
pythonVersion: "3.12.8",
});
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe("graalpy-25.0.0-python-3.12.8");
});
it.each(["linux", "darwin", "win32"])(
"queries the custom venv directly on %s, including paths with spaces",
async (platform) => {
Object.defineProperty(process, "platform", { value: platform });
const inputs = createSetupInputs({
activateEnvironment: true,
venvPath: "/runner temp/custom venv",
workingDirectory: "/different/project",
});
await getPythonRuntimeId(inputs);
const pythonPath =
platform === "win32"
? path.join(inputs.venvPath, "Scripts", "python.exe")
: path.join(inputs.venvPath, "bin", "python");
expect(mockGetExecOutput).toHaveBeenCalledWith(
`"${pythonPath}"`,
["-I", "-c", expect.any(String)],
{ silent: true },
);
},
);
it("escapes quotes in the venv path for @actions/exec", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const inputs = createSetupInputs({
activateEnvironment: true,
venvPath: '/workspace/a "quoted" venv',
});
await getPythonRuntimeId(inputs);
expect(mockGetExecOutput.mock.calls[0][0]).toContain('a \\"quoted\\" venv');
});
it.each(["", "not JSON", "null", "{}"])(
"returns an empty output for invalid interpreter output: %j",
async (stdout) => {
mockGetExecOutput.mockResolvedValue({ exitCode: 0, stderr: "", stdout });
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe("");
expect(mockDebug).toHaveBeenCalled();
},
);
it("returns an empty output for an invalid implementation version", async () => {
mockRuntime({
implementation: "pypy",
implementationVersion: [7, 3, 24, "unknown", 0],
});
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe("");
});
it.each([new Error("interpreter missing"), "interpreter failed"])(
"returns an empty output if the interpreter cannot be queried: %s",
async (error) => {
mockGetExecOutput.mockRejectedValue(error);
expect(
await getPythonRuntimeId(
createSetupInputs({ activateEnvironment: true }),
),
).toBe("");
expect(mockDebug).toHaveBeenCalledWith(
expect.stringContaining(error instanceof Error ? error.message : error),
);
},
expect(await getPythonRuntimeId(inputs)).toBe(
expected ? `${expected}-python-3.11.15` : "",
);
});
it.each([
["linux", 'a "quoted" venv', "bin/python"],
["darwin", "custom venv", "bin/python"],
["win32", "custom venv", "Scripts/python.exe"],
])("passes the venv executable directly on %s", async (platform, name, exe) => {
Object.defineProperty(process, "platform", { value: platform });
const venvPath = join("/runner temp", name);
await getPythonRuntimeId({ ...inputs, venvPath });
expect(mockExecFile).toHaveBeenCalledWith(
join(venvPath, exe),
["-I", "-c", expect.any(String)],
{ encoding: "utf8" },
);
});
it.each([new Error("interpreter missing"), "", "not JSON", "null", "{}"])(
"returns an empty ID for interpreter failure: %s",
async (result) => {
if (result instanceof Error) {
mockExecFile.mockRejectedValue(result);
} else {
mockExecFile.mockResolvedValue({ stderr: "", stdout: result });
}
expect(await getPythonRuntimeId(inputs)).toBe("");
expect(mockDebug).toHaveBeenCalled();
},
);