From 72857ee3da624d0b3a420d1722afbedf0975d5c4 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:58:56 -0400 Subject: [PATCH 1/4] Add integration tests and CI workflow for interpreter detection Add end-to-end integration tests that validate the interpreter detection module against real Python and R executables and a real filesystem. Changes: - integration.test.ts: tests from low-level fsUtils through full getInterpreterDefaults pipeline, using real temp files and executables - interpreter-integration.yaml: CI workflow with matrix of Python 3.9-3.13, R 4.1-4.4, across Ubuntu/macOS/Windows - pull-request.yaml: wire up interpreter-integration as a required job - package.json: add test-integration-interpreters npm script Tests use test.skipIf() when an interpreter isn't available, so the suite runs cleanly in any environment. Co-Authored-By: Claude Opus 4.6 --- .../workflows/interpreter-integration.yaml | 76 ++ .github/workflows/pull-request.yaml | 5 + extensions/vscode/package.json | 3 +- .../src/interpreters/integration.test.ts | 656 ++++++++++++++++++ 4 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/interpreter-integration.yaml create mode 100644 extensions/vscode/src/interpreters/integration.test.ts diff --git a/.github/workflows/interpreter-integration.yaml b/.github/workflows/interpreter-integration.yaml new file mode 100644 index 000000000..b28fe366f --- /dev/null +++ b/.github/workflows/interpreter-integration.yaml @@ -0,0 +1,76 @@ +name: Interpreter Integration Tests +on: [workflow_call] +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + # Python-only configurations + - runner: ubuntu-latest + python-version: "3.9" + r-version: "" + - runner: ubuntu-latest + python-version: "3.10" + r-version: "" + - runner: ubuntu-latest + python-version: "3.11" + r-version: "" + - runner: ubuntu-latest + python-version: "3.12" + r-version: "" + - runner: ubuntu-latest + python-version: "3.13" + r-version: "" + # R-only configurations + - runner: ubuntu-latest + python-version: "" + r-version: "4.1" + - runner: ubuntu-latest + python-version: "" + r-version: "4.3" + - runner: ubuntu-latest + python-version: "" + r-version: "4.4" + # Combined Python + R + - runner: ubuntu-latest + python-version: "3.11" + r-version: "4.3" + - runner: ubuntu-latest + python-version: "3.12" + r-version: "4.4" + # macOS + - runner: macos-latest + python-version: "3.12" + r-version: "4.4" + # Windows + - runner: windows-latest + python-version: "3.12" + r-version: "4.4" + runs-on: ${{ matrix.runner }} + name: >- + ${{ matrix.runner }} + ${{ matrix.python-version && format('py{0}', matrix.python-version) || '' }} + ${{ matrix.r-version && format('R{0}', matrix.r-version) || '' }} + defaults: + run: + working-directory: extensions/vscode + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "**/package-lock.json" + - uses: actions/setup-python@v5 + if: matrix.python-version != '' + with: + python-version: ${{ matrix.python-version }} + - uses: r-lib/actions/setup-r@v2 + if: matrix.r-version != '' + with: + r-version: ${{ matrix.r-version }} + - run: npm install + - run: npm run test-integration-interpreters diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 8c4f889d7..b6bb11b82 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -94,6 +94,11 @@ jobs: if: needs.detect-changes.outputs.has-code == 'true' uses: ./.github/workflows/vscode.yaml + interpreter-integration: + needs: detect-changes + if: needs.detect-changes.outputs.has-code == 'true' + uses: ./.github/workflows/interpreter-integration.yaml + connect-contract-tests: needs: detect-changes if: needs.detect-changes.outputs.has-code == 'true' diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index cda3645ba..ceea77b9c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -655,7 +655,8 @@ "esbuild-tests": "node ./esbuild.tests.mjs", "pretest": "npm run esbuild-tests && npm run esbuild-base", "test": "vscode-test", - "test-unit": "vitest run" + "test-unit": "vitest run", + "test-integration-interpreters": "vitest run src/interpreters/integration.test.ts" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts new file mode 100644 index 000000000..bddcdb554 --- /dev/null +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -0,0 +1,656 @@ +// Copyright (C) 2026 by Posit Software, PBC. +// +// Integration tests for the interpreter detection pipeline. +// +// Unlike the unit tests (e.g. pythonRequires.test.ts, rInterpreter.test.ts) +// which mock the filesystem and child processes, these tests exercise the +// real code paths against: +// - A real temporary filesystem (created with mkdtemp, cleaned up after) +// - Real Python and R executables when available on the PATH +// +// Tests that require a specific interpreter use `test.skipIf(!available)` so +// the suite can run in any environment without failures. In CI, the +// interpreter-integration.yaml workflow installs known Python and R versions +// across a matrix of configurations to ensure full coverage. +// +// The tests are organized in layers from low-level to high-level: +// 1. fsUtils – filesystem helpers (readFileText, fileExistsAt) +// 2. getPythonRequires – version constraint extraction from project files +// 3. getRRequires – version constraint extraction from R project files +// 4. detectPythonInterpreter – full Python detection (exec + project files) +// 5. detectRInterpreter – full R detection (exec + project files) +// 6. End-to-end tests – detection + project files combined +// 7. getInterpreterDefaults – top-level orchestrator for both interpreters +// +// Run these tests with: +// npx vitest run src/interpreters/integration.test.ts +// +// Or as part of the dedicated npm script: +// npm run test-integration-interpreters + +import { execFile } from "child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { readFileText, fileExistsAt } from "./fsUtils"; +import { getPythonRequires } from "./pythonRequires"; +import { getRRequires } from "./rRequires"; +import { getInterpreterDefaults } from "./index"; +import { + detectPythonInterpreter, + clearPythonVersionCache, +} from "./pythonInterpreter"; +import { detectRInterpreter } from "./rInterpreter"; + +const execFileAsync = promisify(execFile); + +// Shared temp directory for simple tests that don't need isolation. +// Tests that verify priority ordering or need a clean project directory +// create their own temp dirs to avoid file leakage between tests. +let tmpDir: string; + +beforeAll(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "publisher-test-")); +}); + +afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +/** Write a file into the shared temp project directory. */ +async function writeProjectFile(filename: string, content: string) { + await writeFile(path.join(tmpDir, filename), content, "utf-8"); +} + +/** + * Check if an executable is available on PATH by attempting to run it. + * Used to determine whether interpreter-dependent tests should be skipped. + */ +async function isExecutableAvailable(name: string): Promise { + try { + await execFileAsync(name, ["--version"]); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Layer 1: fsUtils – verify basic file I/O helpers against real disk +// --------------------------------------------------------------------------- + +describe("fsUtils (real filesystem)", () => { + test("readFileText reads an existing file", async () => { + const filePath = path.join(tmpDir, "hello.txt"); + await writeFile(filePath, "hello world", "utf-8"); + const result = await readFileText(filePath); + expect(result).toBe("hello world"); + }); + + test("readFileText returns null for a missing file", async () => { + const result = await readFileText(path.join(tmpDir, "nonexistent.txt")); + expect(result).toBeNull(); + }); + + test("fileExistsAt returns true for an existing file", async () => { + const filePath = path.join(tmpDir, "exists.txt"); + await writeFile(filePath, "", "utf-8"); + expect(await fileExistsAt(filePath)).toBe(true); + }); + + test("fileExistsAt returns false for a missing file", async () => { + expect(await fileExistsAt(path.join(tmpDir, "nope.txt"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Layer 2: getPythonRequires – version constraint extraction from real files +// Each test creates its own temp dir to avoid cross-test file interference. +// --------------------------------------------------------------------------- + +describe("getPythonRequires (real filesystem)", () => { + test("returns empty string when project has no python config files", async () => { + const emptyDir = await mkdtemp(path.join(os.tmpdir(), "publisher-empty-")); + try { + expect(await getPythonRequires(emptyDir)).toBe(""); + } finally { + await rm(emptyDir, { recursive: true, force: true }); + } + }); + + test("reads .python-version file", async () => { + await writeProjectFile(".python-version", "3.11.4"); + const result = await getPythonRequires(tmpDir); + expect(result).toBe("~=3.11.0"); + }); + + test("reads requires-python from pyproject.toml", async () => { + // Use a fresh dir so .python-version doesn't interfere + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyproj-")); + try { + await writeFile( + path.join(dir, "pyproject.toml"), + '[project]\nrequires-python = ">=3.9"\n', + "utf-8", + ); + expect(await getPythonRequires(dir)).toBe(">=3.9"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("reads python_requires from setup.cfg", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-setupcfg-")); + try { + await writeFile( + path.join(dir, "setup.cfg"), + "[options]\npython_requires = >=3.8\n", + "utf-8", + ); + expect(await getPythonRequires(dir)).toBe(">=3.8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test(".python-version takes priority over pyproject.toml", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-priority-")); + try { + await writeFile(path.join(dir, ".python-version"), "3.10", "utf-8"); + await writeFile( + path.join(dir, "pyproject.toml"), + '[project]\nrequires-python = ">=3.8"\n', + "utf-8", + ); + expect(await getPythonRequires(dir)).toBe("~=3.10.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Layer 3: getRRequires – R version constraint extraction from real files +// --------------------------------------------------------------------------- + +describe("getRRequires (real filesystem)", () => { + test("returns empty string when project has no R config files", async () => { + const emptyDir = await mkdtemp(path.join(os.tmpdir(), "publisher-empty-")); + try { + expect(await getRRequires(emptyDir)).toBe(""); + } finally { + await rm(emptyDir, { recursive: true, force: true }); + } + }); + + test("reads R version from DESCRIPTION Depends", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-desc-")); + try { + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.1.0), utils\n", + "utf-8", + ); + expect(await getRRequires(dir)).toBe(">= 4.1.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("reads R version from renv.lock", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-renv-")); + try { + await writeFile( + path.join(dir, "renv.lock"), + JSON.stringify({ R: { Version: "4.3.1" } }), + "utf-8", + ); + expect(await getRRequires(dir)).toBe("~=4.3.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("DESCRIPTION takes priority over renv.lock", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rpriority-")); + try { + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.0.0)\n", + "utf-8", + ); + await writeFile( + path.join(dir, "renv.lock"), + JSON.stringify({ R: { Version: "4.3.1" } }), + "utf-8", + ); + expect(await getRRequires(dir)).toBe(">= 4.0.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Layer 4: detectPythonInterpreter – runs a real Python executable to get its +// version, and checks for requirements.txt on disk. Tests are skipped when +// no Python interpreter is found on the PATH. +// --------------------------------------------------------------------------- + +describe("detectPythonInterpreter (real interpreter)", async () => { + // Probe for available Python executable; prefer "python3" (Unix convention) + // and fall back to "python" (Windows / pyenv shim convention). + const python3Available = await isExecutableAvailable("python3"); + const pythonAvailable = + python3Available || (await isExecutableAvailable("python")); + const pythonCmd = python3Available ? "python3" : "python"; + + test.skipIf(!pythonAvailable)( + "detects version from a real Python executable", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pydetect-")); + try { + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.packageManager).toBe("auto"); + expect(result.preferredPath).toBe(pythonCmd); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "detects requirements.txt when present", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyreqs-")); + try { + await writeFile(path.join(dir, "requirements.txt"), "flask\n", "utf-8"); + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.packageFile).toBe("requirements.txt"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "returns empty packageFile when requirements.txt is absent", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pynoreqs-")); + try { + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.packageFile).toBe(""); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "finds Python on PATH when no preferred path given", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pypath-")); + try { + const result = await detectPythonInterpreter(dir); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(["python3", "python"]).toContain(result.preferredPath); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test("falls back to PATH when preferred path is bogus", async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pybogus-")); + try { + const result = await detectPythonInterpreter( + dir, + "/nonexistent/python999", + ); + if (pythonAvailable) { + // PATH fallback should find a real interpreter + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.preferredPath).not.toBe("/nonexistent/python999"); + } else { + expect(result.config.version).toBe(""); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Layer 5: detectRInterpreter – runs a real R executable to get its version. +// Tests are skipped when R is not found on the PATH. +// --------------------------------------------------------------------------- + +describe("detectRInterpreter (real interpreter)", async () => { + const rAvailable = await isExecutableAvailable("R"); + + test.skipIf(!rAvailable)( + "detects version from a real R executable", + async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rdetect-")); + try { + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.packageManager).toBe("renv"); + expect(result.preferredPath).toBe("R"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!rAvailable)( + "finds R on PATH when no preferred path given", + async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rpath-")); + try { + const result = await detectRInterpreter(dir); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.preferredPath).toBe("R"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test("falls back to PATH when preferred path is bogus", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rbogus-")); + try { + const result = await detectRInterpreter(dir, "/nonexistent/R999"); + if (rAvailable) { + // PATH fallback should find a real interpreter + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.preferredPath).not.toBe("/nonexistent/R999"); + } else { + expect(result.config.version).toBe(""); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Layer 6a: End-to-end Python detection – combines a real interpreter with +// project config files (.python-version, pyproject.toml, requirements.txt) +// to verify that both the executable version and the project's version +// constraints (requiresPython) are populated correctly together. +// --------------------------------------------------------------------------- + +describe("detectPythonInterpreter end-to-end", async () => { + const python3Available = await isExecutableAvailable("python3"); + const pythonAvailable = + python3Available || (await isExecutableAvailable("python")); + const pythonCmd = python3Available ? "python3" : "python"; + + test.skipIf(!pythonAvailable)( + "populates requiresPython from .python-version alongside real detection", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pye2e-")); + try { + await writeFile(path.join(dir, ".python-version"), "3.11", "utf-8"); + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresPython).toBe("~=3.11.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "populates requiresPython from pyproject.toml", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pye2e-")); + try { + await writeFile( + path.join(dir, "pyproject.toml"), + '[project]\nrequires-python = ">=3.9,<4"\n', + "utf-8", + ); + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresPython).toBe(">=3.9,<4"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "returns all fields together: version, packageFile, requiresPython", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyfull-")); + try { + await writeFile( + path.join(dir, "requirements.txt"), + "flask>=2.0\n", + "utf-8", + ); + await writeFile(path.join(dir, ".python-version"), "3.10.5", "utf-8"); + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.packageFile).toBe("requirements.txt"); + expect(result.config.packageManager).toBe("auto"); + expect(result.config.requiresPython).toBe("~=3.10.0"); + expect(result.preferredPath).toBe(pythonCmd); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "omits requiresPython when no version constraint files exist", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pynoreq-")); + try { + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresPython).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// Layer 6b: End-to-end R detection – combines a real R interpreter with +// project config files (DESCRIPTION, renv.lock) to verify that the +// executable version and requiresR constraint are populated together. +// --------------------------------------------------------------------------- + +describe("detectRInterpreter end-to-end", async () => { + const rAvailable = await isExecutableAvailable("R"); + + test.skipIf(!rAvailable)( + "populates requiresR from DESCRIPTION Depends", + async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-re2e-")); + try { + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.1.0), utils\n", + "utf-8", + ); + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresR).toBe(">= 4.1.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!rAvailable)("populates requiresR from renv.lock", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-re2e-")); + try { + await writeFile( + path.join(dir, "renv.lock"), + JSON.stringify({ R: { Version: "4.2.3" } }), + "utf-8", + ); + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresR).toBe("~=4.2.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test.skipIf(!rAvailable)( + "returns all fields together: version, packageFile, requiresR", + async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rfull-")); + try { + await writeFile( + path.join(dir, "renv.lock"), + JSON.stringify({ + R: { Version: "4.3.1" }, + Packages: {}, + }), + "utf-8", + ); + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.0.0)\n", + "utf-8", + ); + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.packageManager).toBe("renv"); + // DESCRIPTION takes priority over renv.lock for requiresR + expect(result.config.requiresR).toBe(">= 4.0.0"); + expect(result.preferredPath).toBe("R"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!rAvailable)( + "omits requiresR when no version constraint files exist", + async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rnoreq-")); + try { + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresR).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// Layer 7: getInterpreterDefaults – the top-level orchestrator that state.ts +// calls to get defaults for both Python and R in a single call. Verifies +// that the full pipeline works for mixed-language, Python-only, and R-only +// projects. When one interpreter is not explicitly provided, detection will +// attempt to find it via PATH fallback, so expectations account for that. +// --------------------------------------------------------------------------- + +describe("getInterpreterDefaults end-to-end", async () => { + const python3Available = await isExecutableAvailable("python3"); + const pythonAvailable = + python3Available || (await isExecutableAvailable("python")); + const pythonCmd = python3Available ? "python3" : "python"; + const rAvailable = await isExecutableAvailable("R"); + + test.skipIf(!pythonAvailable || !rAvailable)( + "detects both Python and R in a mixed project", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-both-")); + try { + await writeFile(path.join(dir, "requirements.txt"), "numpy\n", "utf-8"); + await writeFile(path.join(dir, ".python-version"), "3.11", "utf-8"); + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.1.0)\n", + "utf-8", + ); + + const result = await getInterpreterDefaults(dir, pythonCmd, "R"); + + expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.python.packageFile).toBe("requirements.txt"); + expect(result.python.requiresPython).toBe("~=3.11.0"); + expect(result.preferredPythonPath).toBe(pythonCmd); + + expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.r.requiresR).toBe(">= 4.1.0"); + expect(result.preferredRPath).toBe("R"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!pythonAvailable)( + "handles Python-only project gracefully", + async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyonly-")); + try { + await writeFile(path.join(dir, "requirements.txt"), "flask\n", "utf-8"); + + const result = await getInterpreterDefaults(dir, pythonCmd); + + expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.python.packageFile).toBe("requirements.txt"); + // R may still be detected via PATH fallback + if (rAvailable) { + expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); + } else { + expect(result.r.version).toBe(""); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!rAvailable)("handles R-only project gracefully", async () => { + clearPythonVersionCache(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-ronly-")); + try { + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.0.0)\n", + "utf-8", + ); + + const result = await getInterpreterDefaults(dir, undefined, "R"); + + expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.r.requiresR).toBe(">= 4.0.0"); + // Python may still be detected via PATH fallback + if (pythonAvailable) { + expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); + } else { + expect(result.python.version).toBe(""); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); From ba9a1897332885160321129e3427f50793e1f4d6 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:32:12 -0400 Subject: [PATCH 2/4] Remove shared tmpDir; each test manages its own temp directory Eliminates the shared tmpDir/beforeAll/afterAll pattern that leaked state between tests. Each test now creates and cleans up its own isolated temp directory via mkdtemp + try/finally. Co-Authored-By: Claude Opus 4.6 --- .../src/interpreters/integration.test.ts | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index bddcdb554..7ab11192c 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -33,7 +33,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { readFileText, fileExistsAt } from "./fsUtils"; import { getPythonRequires } from "./pythonRequires"; import { getRRequires } from "./rRequires"; @@ -46,24 +46,6 @@ import { detectRInterpreter } from "./rInterpreter"; const execFileAsync = promisify(execFile); -// Shared temp directory for simple tests that don't need isolation. -// Tests that verify priority ordering or need a clean project directory -// create their own temp dirs to avoid file leakage between tests. -let tmpDir: string; - -beforeAll(async () => { - tmpDir = await mkdtemp(path.join(os.tmpdir(), "publisher-test-")); -}); - -afterAll(async () => { - await rm(tmpDir, { recursive: true, force: true }); -}); - -/** Write a file into the shared temp project directory. */ -async function writeProjectFile(filename: string, content: string) { - await writeFile(path.join(tmpDir, filename), content, "utf-8"); -} - /** * Check if an executable is available on PATH by attempting to run it. * Used to determine whether interpreter-dependent tests should be skipped. @@ -83,25 +65,45 @@ async function isExecutableAvailable(name: string): Promise { describe("fsUtils (real filesystem)", () => { test("readFileText reads an existing file", async () => { - const filePath = path.join(tmpDir, "hello.txt"); - await writeFile(filePath, "hello world", "utf-8"); - const result = await readFileText(filePath); - expect(result).toBe("hello world"); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); + try { + const filePath = path.join(dir, "hello.txt"); + await writeFile(filePath, "hello world", "utf-8"); + const result = await readFileText(filePath); + expect(result).toBe("hello world"); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); test("readFileText returns null for a missing file", async () => { - const result = await readFileText(path.join(tmpDir, "nonexistent.txt")); - expect(result).toBeNull(); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); + try { + const result = await readFileText(path.join(dir, "nonexistent.txt")); + expect(result).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); test("fileExistsAt returns true for an existing file", async () => { - const filePath = path.join(tmpDir, "exists.txt"); - await writeFile(filePath, "", "utf-8"); - expect(await fileExistsAt(filePath)).toBe(true); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); + try { + const filePath = path.join(dir, "exists.txt"); + await writeFile(filePath, "", "utf-8"); + expect(await fileExistsAt(filePath)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); test("fileExistsAt returns false for a missing file", async () => { - expect(await fileExistsAt(path.join(tmpDir, "nope.txt"))).toBe(false); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); + try { + expect(await fileExistsAt(path.join(dir, "nope.txt"))).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); }); @@ -121,9 +123,14 @@ describe("getPythonRequires (real filesystem)", () => { }); test("reads .python-version file", async () => { - await writeProjectFile(".python-version", "3.11.4"); - const result = await getPythonRequires(tmpDir); - expect(result).toBe("~=3.11.0"); + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyver-")); + try { + await writeFile(path.join(dir, ".python-version"), "3.11.4", "utf-8"); + const result = await getPythonRequires(dir); + expect(result).toBe("~=3.11.0"); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); test("reads requires-python from pyproject.toml", async () => { From eb4b7e5eb4f6664fdec0549ab2095c8e1f2a4d9d Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:35:54 -0400 Subject: [PATCH 3/4] Remove layer references from integration test comments Co-Authored-By: Claude Opus 4.6 --- .../src/interpreters/integration.test.ts | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index 7ab11192c..63b247d3a 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -13,15 +13,6 @@ // interpreter-integration.yaml workflow installs known Python and R versions // across a matrix of configurations to ensure full coverage. // -// The tests are organized in layers from low-level to high-level: -// 1. fsUtils – filesystem helpers (readFileText, fileExistsAt) -// 2. getPythonRequires – version constraint extraction from project files -// 3. getRRequires – version constraint extraction from R project files -// 4. detectPythonInterpreter – full Python detection (exec + project files) -// 5. detectRInterpreter – full R detection (exec + project files) -// 6. End-to-end tests – detection + project files combined -// 7. getInterpreterDefaults – top-level orchestrator for both interpreters -// // Run these tests with: // npx vitest run src/interpreters/integration.test.ts // @@ -60,7 +51,7 @@ async function isExecutableAvailable(name: string): Promise { } // --------------------------------------------------------------------------- -// Layer 1: fsUtils – verify basic file I/O helpers against real disk +// fsUtils – verify basic file I/O helpers against real disk // --------------------------------------------------------------------------- describe("fsUtils (real filesystem)", () => { @@ -108,8 +99,7 @@ describe("fsUtils (real filesystem)", () => { }); // --------------------------------------------------------------------------- -// Layer 2: getPythonRequires – version constraint extraction from real files -// Each test creates its own temp dir to avoid cross-test file interference. +// getPythonRequires – version constraint extraction from real files // --------------------------------------------------------------------------- describe("getPythonRequires (real filesystem)", () => { @@ -179,7 +169,7 @@ describe("getPythonRequires (real filesystem)", () => { }); // --------------------------------------------------------------------------- -// Layer 3: getRRequires – R version constraint extraction from real files +// getRRequires – R version constraint extraction from real files // --------------------------------------------------------------------------- describe("getRRequires (real filesystem)", () => { @@ -241,9 +231,9 @@ describe("getRRequires (real filesystem)", () => { }); // --------------------------------------------------------------------------- -// Layer 4: detectPythonInterpreter – runs a real Python executable to get its -// version, and checks for requirements.txt on disk. Tests are skipped when -// no Python interpreter is found on the PATH. +// detectPythonInterpreter – runs a real Python executable to get its version, +// and checks for requirements.txt on disk. Tests are skipped when no Python +// interpreter is found on the PATH. // --------------------------------------------------------------------------- describe("detectPythonInterpreter (real interpreter)", async () => { @@ -336,8 +326,8 @@ describe("detectPythonInterpreter (real interpreter)", async () => { }); // --------------------------------------------------------------------------- -// Layer 5: detectRInterpreter – runs a real R executable to get its version. -// Tests are skipped when R is not found on the PATH. +// detectRInterpreter – runs a real R executable to get its version. Tests are +// skipped when R is not found on the PATH. // --------------------------------------------------------------------------- describe("detectRInterpreter (real interpreter)", async () => { @@ -390,10 +380,10 @@ describe("detectRInterpreter (real interpreter)", async () => { }); // --------------------------------------------------------------------------- -// Layer 6a: End-to-end Python detection – combines a real interpreter with -// project config files (.python-version, pyproject.toml, requirements.txt) -// to verify that both the executable version and the project's version -// constraints (requiresPython) are populated correctly together. +// End-to-end Python detection – combines a real interpreter with project +// config files (.python-version, pyproject.toml, requirements.txt) to verify +// that both the executable version and the project's version constraints +// (requiresPython) are populated correctly together. // --------------------------------------------------------------------------- describe("detectPythonInterpreter end-to-end", async () => { @@ -479,9 +469,9 @@ describe("detectPythonInterpreter end-to-end", async () => { }); // --------------------------------------------------------------------------- -// Layer 6b: End-to-end R detection – combines a real R interpreter with -// project config files (DESCRIPTION, renv.lock) to verify that the -// executable version and requiresR constraint are populated together. +// End-to-end R detection – combines a real R interpreter with project config +// files (DESCRIPTION, renv.lock) to verify that the executable version and +// requiresR constraint are populated together. // --------------------------------------------------------------------------- describe("detectRInterpreter end-to-end", async () => { @@ -568,11 +558,11 @@ describe("detectRInterpreter end-to-end", async () => { }); // --------------------------------------------------------------------------- -// Layer 7: getInterpreterDefaults – the top-level orchestrator that state.ts -// calls to get defaults for both Python and R in a single call. Verifies -// that the full pipeline works for mixed-language, Python-only, and R-only -// projects. When one interpreter is not explicitly provided, detection will -// attempt to find it via PATH fallback, so expectations account for that. +// getInterpreterDefaults – the top-level orchestrator that state.ts calls to +// get defaults for both Python and R in a single call. Verifies that the full +// pipeline works for mixed-language, Python-only, and R-only projects. When +// one interpreter is not explicitly provided, detection will attempt to find +// it via PATH fallback, so expectations account for that. // --------------------------------------------------------------------------- describe("getInterpreterDefaults end-to-end", async () => { From e43017bf86cdb54ad46c47f3edc13893a169f01b Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:43:43 -0400 Subject: [PATCH 4/4] Consolidate temp dir cleanup into withTempDir helper Replace repeated mkdtemp/try/finally/rm pattern across all tests with a withTempDir(fn) helper that handles creation and cleanup. Co-Authored-By: Claude Opus 4.6 --- .../src/interpreters/integration.test.ts | 377 ++++++------------ 1 file changed, 129 insertions(+), 248 deletions(-) diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index 63b247d3a..f12f877c7 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -37,6 +37,16 @@ import { detectRInterpreter } from "./rInterpreter"; const execFileAsync = promisify(execFile); +/** Create a temp directory, pass it to `fn`, then clean up. */ +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-test-")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + /** * Check if an executable is available on PATH by attempting to run it. * Used to determine whether interpreter-dependent tests should be skipped. @@ -55,47 +65,31 @@ async function isExecutableAvailable(name: string): Promise { // --------------------------------------------------------------------------- describe("fsUtils (real filesystem)", () => { - test("readFileText reads an existing file", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); - try { + test("readFileText reads an existing file", () => + withTempDir(async (dir) => { const filePath = path.join(dir, "hello.txt"); await writeFile(filePath, "hello world", "utf-8"); const result = await readFileText(filePath); expect(result).toBe("hello world"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("readFileText returns null for a missing file", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); - try { + test("readFileText returns null for a missing file", () => + withTempDir(async (dir) => { const result = await readFileText(path.join(dir, "nonexistent.txt")); expect(result).toBeNull(); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("fileExistsAt returns true for an existing file", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); - try { + test("fileExistsAt returns true for an existing file", () => + withTempDir(async (dir) => { const filePath = path.join(dir, "exists.txt"); await writeFile(filePath, "", "utf-8"); expect(await fileExistsAt(filePath)).toBe(true); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("fileExistsAt returns false for a missing file", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-fs-")); - try { + test("fileExistsAt returns false for a missing file", () => + withTempDir(async (dir) => { expect(await fileExistsAt(path.join(dir, "nope.txt"))).toBe(false); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); }); // --------------------------------------------------------------------------- @@ -103,58 +97,40 @@ describe("fsUtils (real filesystem)", () => { // --------------------------------------------------------------------------- describe("getPythonRequires (real filesystem)", () => { - test("returns empty string when project has no python config files", async () => { - const emptyDir = await mkdtemp(path.join(os.tmpdir(), "publisher-empty-")); - try { - expect(await getPythonRequires(emptyDir)).toBe(""); - } finally { - await rm(emptyDir, { recursive: true, force: true }); - } - }); + test("returns empty string when project has no python config files", () => + withTempDir(async (dir) => { + expect(await getPythonRequires(dir)).toBe(""); + })); - test("reads .python-version file", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyver-")); - try { + test("reads .python-version file", () => + withTempDir(async (dir) => { await writeFile(path.join(dir, ".python-version"), "3.11.4", "utf-8"); const result = await getPythonRequires(dir); expect(result).toBe("~=3.11.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("reads requires-python from pyproject.toml", async () => { - // Use a fresh dir so .python-version doesn't interfere - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyproj-")); - try { + test("reads requires-python from pyproject.toml", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "pyproject.toml"), '[project]\nrequires-python = ">=3.9"\n', "utf-8", ); expect(await getPythonRequires(dir)).toBe(">=3.9"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("reads python_requires from setup.cfg", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-setupcfg-")); - try { + test("reads python_requires from setup.cfg", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "setup.cfg"), "[options]\npython_requires = >=3.8\n", "utf-8", ); expect(await getPythonRequires(dir)).toBe(">=3.8"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test(".python-version takes priority over pyproject.toml", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-priority-")); - try { + test(".python-version takes priority over pyproject.toml", () => + withTempDir(async (dir) => { await writeFile(path.join(dir, ".python-version"), "3.10", "utf-8"); await writeFile( path.join(dir, "pyproject.toml"), @@ -162,10 +138,7 @@ describe("getPythonRequires (real filesystem)", () => { "utf-8", ); expect(await getPythonRequires(dir)).toBe("~=3.10.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); }); // --------------------------------------------------------------------------- @@ -173,46 +146,33 @@ describe("getPythonRequires (real filesystem)", () => { // --------------------------------------------------------------------------- describe("getRRequires (real filesystem)", () => { - test("returns empty string when project has no R config files", async () => { - const emptyDir = await mkdtemp(path.join(os.tmpdir(), "publisher-empty-")); - try { - expect(await getRRequires(emptyDir)).toBe(""); - } finally { - await rm(emptyDir, { recursive: true, force: true }); - } - }); + test("returns empty string when project has no R config files", () => + withTempDir(async (dir) => { + expect(await getRRequires(dir)).toBe(""); + })); - test("reads R version from DESCRIPTION Depends", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-desc-")); - try { + test("reads R version from DESCRIPTION Depends", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "DESCRIPTION"), "Package: mypkg\nDepends: R (>= 4.1.0), utils\n", "utf-8", ); expect(await getRRequires(dir)).toBe(">= 4.1.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("reads R version from renv.lock", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-renv-")); - try { + test("reads R version from renv.lock", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "renv.lock"), JSON.stringify({ R: { Version: "4.3.1" } }), "utf-8", ); expect(await getRRequires(dir)).toBe("~=4.3.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); - test("DESCRIPTION takes priority over renv.lock", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rpriority-")); - try { + test("DESCRIPTION takes priority over renv.lock", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "DESCRIPTION"), "Package: mypkg\nDepends: R (>= 4.0.0)\n", @@ -224,10 +184,7 @@ describe("getRRequires (real filesystem)", () => { "utf-8", ); expect(await getRRequires(dir)).toBe(">= 4.0.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); }); // --------------------------------------------------------------------------- @@ -246,82 +203,63 @@ describe("detectPythonInterpreter (real interpreter)", async () => { test.skipIf(!pythonAvailable)( "detects version from a real Python executable", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pydetect-")); - try { + return withTempDir(async (dir) => { const result = await detectPythonInterpreter(dir, pythonCmd); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.packageManager).toBe("auto"); expect(result.preferredPath).toBe(pythonCmd); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); - test.skipIf(!pythonAvailable)( - "detects requirements.txt when present", - async () => { - clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyreqs-")); - try { - await writeFile(path.join(dir, "requirements.txt"), "flask\n", "utf-8"); - const result = await detectPythonInterpreter(dir, pythonCmd); - expect(result.config.packageFile).toBe("requirements.txt"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, - ); + test.skipIf(!pythonAvailable)("detects requirements.txt when present", () => { + clearPythonVersionCache(); + return withTempDir(async (dir) => { + await writeFile(path.join(dir, "requirements.txt"), "flask\n", "utf-8"); + const result = await detectPythonInterpreter(dir, pythonCmd); + expect(result.config.packageFile).toBe("requirements.txt"); + }); + }); test.skipIf(!pythonAvailable)( "returns empty packageFile when requirements.txt is absent", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pynoreqs-")); - try { + return withTempDir(async (dir) => { const result = await detectPythonInterpreter(dir, pythonCmd); expect(result.config.packageFile).toBe(""); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); test.skipIf(!pythonAvailable)( "finds Python on PATH when no preferred path given", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pypath-")); - try { + return withTempDir(async (dir) => { const result = await detectPythonInterpreter(dir); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(["python3", "python"]).toContain(result.preferredPath); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); - test("falls back to PATH when preferred path is bogus", async () => { + test("falls back to PATH when preferred path is bogus", () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pybogus-")); - try { + return withTempDir(async (dir) => { const result = await detectPythonInterpreter( dir, "/nonexistent/python999", ); if (pythonAvailable) { - // PATH fallback should find a real interpreter expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.preferredPath).not.toBe("/nonexistent/python999"); } else { expect(result.config.version).toBe(""); } - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }); }); @@ -333,50 +271,33 @@ describe("detectPythonInterpreter (real interpreter)", async () => { describe("detectRInterpreter (real interpreter)", async () => { const rAvailable = await isExecutableAvailable("R"); - test.skipIf(!rAvailable)( - "detects version from a real R executable", - async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rdetect-")); - try { - const result = await detectRInterpreter(dir, "R"); - expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); - expect(result.config.packageManager).toBe("renv"); - expect(result.preferredPath).toBe("R"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, + test.skipIf(!rAvailable)("detects version from a real R executable", () => + withTempDir(async (dir) => { + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.packageManager).toBe("renv"); + expect(result.preferredPath).toBe("R"); + }), ); - test.skipIf(!rAvailable)( - "finds R on PATH when no preferred path given", - async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rpath-")); - try { - const result = await detectRInterpreter(dir); - expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); - expect(result.preferredPath).toBe("R"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, + test.skipIf(!rAvailable)("finds R on PATH when no preferred path given", () => + withTempDir(async (dir) => { + const result = await detectRInterpreter(dir); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.preferredPath).toBe("R"); + }), ); - test("falls back to PATH when preferred path is bogus", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rbogus-")); - try { + test("falls back to PATH when preferred path is bogus", () => + withTempDir(async (dir) => { const result = await detectRInterpreter(dir, "/nonexistent/R999"); if (rAvailable) { - // PATH fallback should find a real interpreter expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.preferredPath).not.toBe("/nonexistent/R999"); } else { expect(result.config.version).toBe(""); } - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + })); }); // --------------------------------------------------------------------------- @@ -394,26 +315,22 @@ describe("detectPythonInterpreter end-to-end", async () => { test.skipIf(!pythonAvailable)( "populates requiresPython from .python-version alongside real detection", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pye2e-")); - try { + return withTempDir(async (dir) => { await writeFile(path.join(dir, ".python-version"), "3.11", "utf-8"); const result = await detectPythonInterpreter(dir, pythonCmd); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.requiresPython).toBe("~=3.11.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); test.skipIf(!pythonAvailable)( "populates requiresPython from pyproject.toml", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pye2e-")); - try { + return withTempDir(async (dir) => { await writeFile( path.join(dir, "pyproject.toml"), '[project]\nrequires-python = ">=3.9,<4"\n', @@ -422,18 +339,15 @@ describe("detectPythonInterpreter end-to-end", async () => { const result = await detectPythonInterpreter(dir, pythonCmd); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.requiresPython).toBe(">=3.9,<4"); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); test.skipIf(!pythonAvailable)( "returns all fields together: version, packageFile, requiresPython", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyfull-")); - try { + return withTempDir(async (dir) => { await writeFile( path.join(dir, "requirements.txt"), "flask>=2.0\n", @@ -446,24 +360,19 @@ describe("detectPythonInterpreter end-to-end", async () => { expect(result.config.packageManager).toBe("auto"); expect(result.config.requiresPython).toBe("~=3.10.0"); expect(result.preferredPath).toBe(pythonCmd); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); test.skipIf(!pythonAvailable)( "omits requiresPython when no version constraint files exist", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pynoreq-")); - try { + return withTempDir(async (dir) => { const result = await detectPythonInterpreter(dir, pythonCmd); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.requiresPython).toBeUndefined(); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); }); @@ -477,28 +386,21 @@ describe("detectPythonInterpreter end-to-end", async () => { describe("detectRInterpreter end-to-end", async () => { const rAvailable = await isExecutableAvailable("R"); - test.skipIf(!rAvailable)( - "populates requiresR from DESCRIPTION Depends", - async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-re2e-")); - try { - await writeFile( - path.join(dir, "DESCRIPTION"), - "Package: mypkg\nDepends: R (>= 4.1.0), utils\n", - "utf-8", - ); - const result = await detectRInterpreter(dir, "R"); - expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); - expect(result.config.requiresR).toBe(">= 4.1.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, + test.skipIf(!rAvailable)("populates requiresR from DESCRIPTION Depends", () => + withTempDir(async (dir) => { + await writeFile( + path.join(dir, "DESCRIPTION"), + "Package: mypkg\nDepends: R (>= 4.1.0), utils\n", + "utf-8", + ); + const result = await detectRInterpreter(dir, "R"); + expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(result.config.requiresR).toBe(">= 4.1.0"); + }), ); - test.skipIf(!rAvailable)("populates requiresR from renv.lock", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-re2e-")); - try { + test.skipIf(!rAvailable)("populates requiresR from renv.lock", () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "renv.lock"), JSON.stringify({ R: { Version: "4.2.3" } }), @@ -507,16 +409,13 @@ describe("detectRInterpreter end-to-end", async () => { const result = await detectRInterpreter(dir, "R"); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.requiresR).toBe("~=4.2.0"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + }), + ); test.skipIf(!rAvailable)( "returns all fields together: version, packageFile, requiresR", - async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rfull-")); - try { + () => + withTempDir(async (dir) => { await writeFile( path.join(dir, "renv.lock"), JSON.stringify({ @@ -536,24 +435,17 @@ describe("detectRInterpreter end-to-end", async () => { // DESCRIPTION takes priority over renv.lock for requiresR expect(result.config.requiresR).toBe(">= 4.0.0"); expect(result.preferredPath).toBe("R"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, + }), ); test.skipIf(!rAvailable)( "omits requiresR when no version constraint files exist", - async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-rnoreq-")); - try { + () => + withTempDir(async (dir) => { const result = await detectRInterpreter(dir, "R"); expect(result.config.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.config.requiresR).toBeUndefined(); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, + }), ); }); @@ -574,10 +466,9 @@ describe("getInterpreterDefaults end-to-end", async () => { test.skipIf(!pythonAvailable || !rAvailable)( "detects both Python and R in a mixed project", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-both-")); - try { + return withTempDir(async (dir) => { await writeFile(path.join(dir, "requirements.txt"), "numpy\n", "utf-8"); await writeFile(path.join(dir, ".python-version"), "3.11", "utf-8"); await writeFile( @@ -596,40 +487,33 @@ describe("getInterpreterDefaults end-to-end", async () => { expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.r.requiresR).toBe(">= 4.1.0"); expect(result.preferredRPath).toBe("R"); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); test.skipIf(!pythonAvailable)( "handles Python-only project gracefully", - async () => { + () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-pyonly-")); - try { + return withTempDir(async (dir) => { await writeFile(path.join(dir, "requirements.txt"), "flask\n", "utf-8"); const result = await getInterpreterDefaults(dir, pythonCmd); expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.python.packageFile).toBe("requirements.txt"); - // R may still be detected via PATH fallback if (rAvailable) { expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); } else { expect(result.r.version).toBe(""); } - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }, ); - test.skipIf(!rAvailable)("handles R-only project gracefully", async () => { + test.skipIf(!rAvailable)("handles R-only project gracefully", () => { clearPythonVersionCache(); - const dir = await mkdtemp(path.join(os.tmpdir(), "publisher-ronly-")); - try { + return withTempDir(async (dir) => { await writeFile( path.join(dir, "DESCRIPTION"), "Package: mypkg\nDepends: R (>= 4.0.0)\n", @@ -640,14 +524,11 @@ describe("getInterpreterDefaults end-to-end", async () => { expect(result.r.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.r.requiresR).toBe(">= 4.0.0"); - // Python may still be detected via PATH fallback if (pythonAvailable) { expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); } else { expect(result.python.version).toBe(""); } - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }); });