From 79ac36e39effd85602791528daeb46d23e967b1c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:12:07 -0400 Subject: [PATCH 1/4] Wire TypeScript interpreter module into state.ts, remove Go endpoint Replace the HTTP call to the Go /api/interpreters endpoint with an in-process call to the TypeScript getInterpreterDefaults() function added in the previous PR. Changes: - configurations.ts: add requiresPython/requiresR fields to PythonConfig/RConfig types and fill-in logic in UpdateConfigWithDefaults - state.ts: use getInterpreterDefaults() instead of api.interpreters.get() - client.ts: remove Interpreters resource import and property - Delete Interpreters.ts API resource class - Delete get_interpreters.go and its tests - Remove /api/interpreters route from api_service.go Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/api/client.ts | 5 +- .../vscode/src/api/resources/Interpreters.ts | 31 --- .../vscode/src/api/types/configurations.ts | 7 + extensions/vscode/src/state.test.ts | 23 +- extensions/vscode/src/state.ts | 20 +- internal/services/api/api_service.go | 4 - internal/services/api/get_interpreters.go | 70 ------ .../services/api/get_interpreters_test.go | 230 ------------------ 8 files changed, 30 insertions(+), 360 deletions(-) delete mode 100644 extensions/vscode/src/api/resources/Interpreters.ts delete mode 100644 internal/services/api/get_interpreters.go delete mode 100644 internal/services/api/get_interpreters_test.go diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index d655e2c8a0..2ed51a3f69 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -6,7 +6,7 @@ import { Credentials } from "./resources/Credentials"; import { ContentRecords } from "./resources/ContentRecords"; import { Configurations } from "./resources/Configurations"; import { Files } from "./resources/Files"; -import { Interpreters } from "./resources/Interpreters"; + import { Packages } from "./resources/Packages"; import { Secrets } from "./resources/Secrets"; import { SnowflakeConnections } from "./resources/SnowflakeConnections"; @@ -20,7 +20,6 @@ class PublishingClientApi { private client; configurations: Configurations; - interpreters: Interpreters; credentials: Credentials; contentRecords: ContentRecords; files: Files; @@ -57,7 +56,7 @@ class PublishingClientApi { this.credentials = new Credentials(this.client); this.contentRecords = new ContentRecords(this.client); this.files = new Files(this.client); - this.interpreters = new Interpreters(this.client); + this.packages = new Packages(this.client); this.secrets = new Secrets(this.client); this.integrationRequests = new IntegrationRequests(this.client); diff --git a/extensions/vscode/src/api/resources/Interpreters.ts b/extensions/vscode/src/api/resources/Interpreters.ts deleted file mode 100644 index 57f90a6029..0000000000 --- a/extensions/vscode/src/api/resources/Interpreters.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) 2025 by Posit Software, PBC. - -import { AxiosInstance } from "axios"; - -import { PythonExecutable, RExecutable } from "../../types/shared"; -import { InterpreterDefaults } from "../types/interpreters"; - -export class Interpreters { - private client: AxiosInstance; - - constructor(client: AxiosInstance) { - this.client = client; - } - - // Returns: - // 200 - success - // 500 - internal server error - get( - dir: string, - r: RExecutable | undefined, - python: PythonExecutable | undefined, - ) { - return this.client.get(`/interpreters`, { - params: { - dir, - r: r !== undefined ? r.rPath : "", - python: python !== undefined ? python.pythonPath : "", - }, - }); - } -} diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index cc6465ee08..ed9fb149c5 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -217,6 +217,9 @@ export function UpdateConfigWithDefaults( if (!config.configuration.r.packageManager) { config.configuration.r.packageManager = defaults.r.packageManager; } + if (!config.configuration.r.requiresR) { + config.configuration.r.requiresR = defaults.r.requiresR; + } } if (config.configuration.python !== undefined) { if (!config.configuration.python.version) { @@ -229,6 +232,10 @@ export function UpdateConfigWithDefaults( config.configuration.python.packageManager = defaults.python.packageManager; } + if (!config.configuration.python.requiresPython) { + config.configuration.python.requiresPython = + defaults.python.requiresPython; + } } return config; } diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 41b9cee27e..98040f77a9 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -26,18 +26,6 @@ class mockApiClient { list: vi.fn(), reset: vi.fn(), }; - - readonly interpreters = { - get: vi.fn(() => { - return { - data: { - dir: "/usr/proj", - r: "/usr/bin/r", - python: "/usr/bin/python", - }, - }; - }), - }; } const mockClient = new mockApiClient(); @@ -71,6 +59,17 @@ vi.mock("src/utils/vscode", () => ({ getRInterpreterPath: vi.fn(), })); +vi.mock("src/interpreters", () => ({ + getInterpreterDefaults: vi.fn(() => + Promise.resolve({ + python: { version: "", packageFile: "", packageManager: "" }, + preferredPythonPath: "", + r: { version: "", packageFile: "", packageManager: "" }, + preferredRPath: "", + }), + ), +})); + const mockSyncAllCredentials = vi.fn(); vi.mock("src/credentialSecretStorage", () => ({ syncAllCredentials: (...args: unknown[]) => mockSyncAllCredentials(...args), diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 2e95ea9aae..d9082c3492 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -25,6 +25,7 @@ import { useApi, } from "src/api"; import { normalizeURL } from "src/utils/url"; +import { getInterpreterDefaults } from "src/interpreters"; import { showProgress } from "src/utils/progress"; import { getStatusFromError, @@ -249,15 +250,14 @@ export class PublisherState implements Disposable { root, ); - const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const defaults = await api.interpreters.get( + const defaults = await getInterpreterDefaults( contentRecord.projectDir, - r, - python, + python?.pythonPath, + r?.rPath, ); - const updated = UpdateConfigWithDefaults(cfg, defaults.data); + const updated = UpdateConfigWithDefaults(cfg, defaults); // its not foolproof, but it may help if (!this.findConfig(updated.configurationName, updated.projectDir)) { this.configurations.push(updated); @@ -332,16 +332,16 @@ export class PublisherState implements Disposable { return; } - const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); const configs = await loadAllConfigurationsRecursive(root); - const defaults = await api.interpreters.get(".", r, python); - this.configurations = UpdateAllConfigsWithDefaults( - configs, - defaults.data, + const defaults = await getInterpreterDefaults( + ".", + python?.pythonPath, + r?.rPath, ); + this.configurations = UpdateAllConfigsWithDefaults(configs, defaults); }, ); } catch (error: unknown) { diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 99c0d22099..c448828276 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -156,10 +156,6 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("deployments", "{name}", "environment"), GetDeploymentEnvironmentHandlerFunc(base, log, lister)). Methods(http.MethodGet) - // GET /api/interpreters - r.Handle(ToPath("interpreters"), GetActiveInterpretersHandlerFunc(base, log)). - Methods(http.MethodGet) - // POST /api/packages/python/scan r.Handle(ToPath("packages", "python", "scan"), NewPostPackagesPythonScanHandler(base, log)). Methods(http.MethodPost) diff --git a/internal/services/api/get_interpreters.go b/internal/services/api/get_interpreters.go deleted file mode 100644 index 3df34fb74c..0000000000 --- a/internal/services/api/get_interpreters.go +++ /dev/null @@ -1,70 +0,0 @@ -package api - -// Copyright (C) 2025 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" -) - -var interpretersFromRequest = InterpretersFromRequest - -// getInterpreterResponse is the format of returned interpreter data. -// It represents the defaults of the active interpreters, passed in -// the request. -type getInterpreterResponse struct { - Python *config.Python `json:"python,omitempty"` - PreferredPythonPath string `json:"preferredPythonPath,omitempty"` - R *config.R `json:"r,omitempty"` - PreferredRPath string `json:"preferredRPath,omitempty"` -} - -// toGetInterpreterResponse converts interpreter objects -// to the DTO type we return from the API. -func toGetInterpreterResponse(rInterpreter interpreters.RInterpreter, pythonInterpreter interpreters.PythonInterpreter) getInterpreterResponse { - - rConfig := &config.R{} - rConfig.FillDefaults(rInterpreter) - preferredRPath := "" - if rInterpreter != nil { - preferredRPath = (rInterpreter).GetPreferredPath() - } - - pythonConfig := &config.Python{} - pythonConfig.FillDefaults(pythonInterpreter) - preferredPythonPath := "" - if pythonInterpreter != nil { - preferredPythonPath = (pythonInterpreter).GetPreferredPath() - } - - return getInterpreterResponse{ - R: rConfig, - PreferredRPath: preferredRPath, - Python: pythonConfig, - PreferredPythonPath: preferredPythonPath, - } -} - -func GetActiveInterpretersHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - projectDir, _, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - rInterpreter, pythonInterpreter, err := interpretersFromRequest(projectDir, w, req, log) - if err != nil { - // Response already returned by InterpretersFromRequest - return - } - - response := toGetInterpreterResponse(rInterpreter, pythonInterpreter) - w.Header().Set("content-type", "application/json") - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/get_interpreters_test.go b/internal/services/api/get_interpreters_test.go deleted file mode 100644 index 4232c9e809..0000000000 --- a/internal/services/api/get_interpreters_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" -) - -type GetInterpretersSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetInterpretersSuite(t *testing.T) { - suite.Run(t, new(GetInterpretersSuite)) -} - -func (s *GetInterpretersSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetInterpretersSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetInterpretersSuite) createMockRInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(true) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("R", s.cwd.Fs()), nil) - iMock.On("GetRVersion").Return("3.4.5", nil) - relPath := util.NewRelativePath("renv.lock", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, true, nil) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - iMock.On("GetRRequires").Return(">=3.1.1") - return iMock -} - -func (s *GetInterpretersSuite) createMockRMissingInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - missingError := types.NewAgentError(types.ErrorRExecNotFound, errors.New("no r"), nil) - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(false) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetRVersion").Return("", missingError) - relPath := util.NewRelativePath("", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, false, missingError) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - iMock.On("IsPythonExecutableValid").Return(true) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("/bin/python", s.cwd.Fs()), nil) - iMock.On("GetPythonVersion").Return("1.2.3", nil) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return(">=1.1.3") - iMock.On("GetLockFilePath").Return("requirements.txt", true, nil) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonMissingInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - missingError := types.NewAgentError(types.ErrorPythonExecNotFound, errors.New("no python"), nil) - iMock.On("IsPythonExecutableValid").Return(false) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetPythonVersion").Return("", missingError) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return("") - iMock.On("GetLockFilePath").Return("", false, missingError) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenPassedIn() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRInterpreter() - python := s.createMockPythonInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "1.2.3", - PackageFile: "requirements.txt", - PackageManager: "pip", - RequiresPythonVersion: ">=1.1.3", - } - expectedR := &config.R{ - Version: "3.4.5", - PackageFile: "renv.lock", - PackageManager: "renv", - RequiresRVersion: ">=3.1.1", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenNoneFound() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRMissingInterpreter() - python := s.createMockPythonMissingInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "", - PackageFile: "", - PackageManager: "", - } - expectedR := &config.R{ - Version: "", - PackageFile: "", - PackageManager: "", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -} From c2fbe3955358a3c4784264c0475127232aad3cf8 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:02:28 -0400 Subject: [PATCH 2/4] Add tests for requiresPython/requiresR fill-in logic in UpdateConfigWithDefaults Co-Authored-By: Claude Opus 4.6 --- .../src/api/types/configurations.test.ts | 86 ++++++++++++++++++- .../src/test/unit-test-utils/factories.ts | 2 + 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/extensions/vscode/src/api/types/configurations.test.ts b/extensions/vscode/src/api/types/configurations.test.ts index bcba28c280..bcb2ee281b 100644 --- a/extensions/vscode/src/api/types/configurations.test.ts +++ b/extensions/vscode/src/api/types/configurations.test.ts @@ -45,11 +45,13 @@ describe("Configurations Types", () => { version: "4.4.0", packageFile: "renv.lock", packageManager: "renv", + requiresR: ">= 4.4.0", }); expect(config.configuration.python).toEqual({ version: "3.11.0", packageFile: "requirements.txt", packageManager: "pip", + requiresPython: ">=3.11", }); }); @@ -74,8 +76,14 @@ describe("Configurations Types", () => { UpdateConfigWithDefaults(config, interpreterDefaults); - const expectedRConf: RConfig = { ...incomingIntprConfig }; - const expectedPythonConf: PythonConfig = { ...incomingIntprConfig }; + const expectedRConf: RConfig = { + ...incomingIntprConfig, + requiresR: ">= 4.4.0", + }; + const expectedPythonConf: PythonConfig = { + ...incomingIntprConfig, + requiresPython: ">=3.11", + }; if (version === "") { expectedRConf.version = "4.4.0"; @@ -96,6 +104,78 @@ describe("Configurations Types", () => { expect(config.configuration.python).toEqual(expectedPythonConf); }, ); + + test("fills in requiresPython from defaults when config has no requiresPython", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { ...blankIntprConf }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.python!.requiresPython).toBe(">=3.11"); + }); + + test("preserves existing requiresPython when already set", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { + ...blankIntprConf, + requiresPython: ">=3.9,<4", + }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.python!.requiresPython).toBe(">=3.9,<4"); + }); + + test("fills in requiresR from defaults when config has no requiresR", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { ...blankIntprConf }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.r!.requiresR).toBe(">= 4.4.0"); + }); + + test("preserves existing requiresR when already set", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { + ...blankIntprConf, + requiresR: ">= 4.1.0", + }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.r!.requiresR).toBe(">= 4.1.0"); + }); + + test("does not set requiresPython when defaults have no requiresPython", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { ...blankIntprConf }; + + const defaultsWithoutRequires: InterpreterDefaults = { + ...interpreterDefaults, + python: { + version: "3.11.0", + packageFile: "requirements.txt", + packageManager: "pip", + }, + }; + + UpdateConfigWithDefaults(config, defaultsWithoutRequires); + expect(config.configuration.python!.requiresPython).toBeUndefined(); + }); + + test("does not set requiresR when defaults have no requiresR", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { ...blankIntprConf }; + + const defaultsWithoutRequires: InterpreterDefaults = { + ...interpreterDefaults, + r: { + version: "4.4.0", + packageFile: "renv.lock", + packageManager: "renv", + }, + }; + + UpdateConfigWithDefaults(config, defaultsWithoutRequires); + expect(config.configuration.r!.requiresR).toBeUndefined(); + }); }); describe("UpdateAllConfigsWithDefaults", () => { @@ -119,11 +199,13 @@ describe("Configurations Types", () => { version: "4.4.0", packageFile: "renv.lock", packageManager: "renv", + requiresR: ">= 4.4.0", }); expect(cf.configuration.python).toEqual({ version: "3.11.0", packageFile: "requirements.txt", packageManager: "pip", + requiresPython: ">=3.11", }); }); }); diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index b8d20d36a7..2f72b5c5b6 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -45,12 +45,14 @@ export const interpreterDefaultsFactory = Factory.define( packageFile: "requirements.txt", packageManager: "pip", version: "3.11.0", + requiresPython: ">=3.11", }, preferredRPath: "usr/bin/R", r: { packageFile: "renv.lock", packageManager: "renv", version: "4.4.0", + requiresR: ">= 4.4.0", }, }), ); From d83eec14f79a4a96a943ec0015f79a117b92b22c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:00:03 -0400 Subject: [PATCH 3/4] Resolve projectDir to absolute path before passing to getInterpreterDefaults The TypeScript interpreter module uses projectDir for filesystem operations that resolve relative to process.cwd(), not the workspace root. The old Go endpoint handled this server-side. Fix by joining workspace root with projectDir in state.ts before calling getInterpreterDefaults. Co-Authored-By: Claude Opus 4.6 --- .../src/interpreters/integration.test.ts | 21 ++++++++++++++++ extensions/vscode/src/state.test.ts | 24 ++++++++++++++++++- extensions/vscode/src/state.ts | 6 +++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index f12f877c7c..bb10f650a9 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -531,4 +531,25 @@ describe("getInterpreterDefaults end-to-end", async () => { } }); }); + + test.skipIf(!pythonAvailable)( + "detects requirements.txt when given an absolute projectDir", + () => { + clearPythonVersionCache(); + return withTempDir(async (dir) => { + await writeFile( + path.join(dir, "requirements.txt"), + "requests\n", + "utf-8", + ); + + // dir is already absolute (from mkdtemp), matching the fixed call sites + // in state.ts which now resolve projectDir against the workspace root. + const result = await getInterpreterDefaults(dir, pythonCmd); + + expect(result.python.packageFile).toBe("requirements.txt"); + expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + }, + ); }); diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 98040f77a9..2cec5729ab 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -15,6 +15,7 @@ import { LocalState } from "./constants"; import { PublisherState } from "./state"; import { AllContentRecordTypes, PreContentRecord } from "src/api"; import { ConfigurationLoadError } from "src/toml"; +import { getInterpreterDefaults } from "src/interpreters"; class mockApiClient { readonly contentRecords = { @@ -332,6 +333,13 @@ describe("PublisherState", () => { expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); + // getInterpreterDefaults should receive absolute path (workspace root + projectDir) + expect(vi.mocked(getInterpreterDefaults)).toHaveBeenCalledWith( + `/workspace/${contentRecord.projectDir}`, + undefined, + undefined, + ); + // second time calls from cache currentConfig = await publisherState.getSelectedConfiguration(); @@ -593,7 +601,21 @@ describe("PublisherState", () => { test.todo("refreshContentRecords", () => {}); - test.todo("refreshConfigurations", () => {}); + test("refreshConfigurations passes absolute workspace root to getInterpreterDefaults", async () => { + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + mockLoadAllConfigurationsRecursive.mockResolvedValue([]); + vi.mocked(getInterpreterDefaults).mockClear(); + + await publisherState.refreshConfigurations(); + + expect(vi.mocked(getInterpreterDefaults)).toHaveBeenCalledWith( + "/workspace", + undefined, + undefined, + ); + }); test.todo("validConfigs", () => {}); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index d9082c3492..b714b4f616 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -1,5 +1,7 @@ // Copyright (C) 2025 by Posit Software, PBC. +import path from "node:path"; + import { Disposable, env, @@ -253,7 +255,7 @@ export class PublisherState implements Disposable { const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); const defaults = await getInterpreterDefaults( - contentRecord.projectDir, + path.join(root, contentRecord.projectDir), python?.pythonPath, r?.rPath, ); @@ -337,7 +339,7 @@ export class PublisherState implements Disposable { const configs = await loadAllConfigurationsRecursive(root); const defaults = await getInterpreterDefaults( - ".", + root, python?.pythonPath, r?.rPath, ); From 748e129285b4c8498ede6b544debbb90e8dfa205 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:17:45 -0400 Subject: [PATCH 4/4] Fix cross-platform test assertion for projectDir path joining Use path.join() in the test assertion instead of a template literal with forward slashes, so the expected value matches on Windows where path.join produces backslashes. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/state.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 2cec5729ab..b8f6ca6035 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -1,5 +1,7 @@ // Copyright (C) 2024 by Posit Software, PBC. +import path from "node:path"; + import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { window } from "vscode"; import { AxiosError, AxiosHeaders } from "axios"; @@ -335,7 +337,7 @@ describe("PublisherState", () => { // getInterpreterDefaults should receive absolute path (workspace root + projectDir) expect(vi.mocked(getInterpreterDefaults)).toHaveBeenCalledWith( - `/workspace/${contentRecord.projectDir}`, + path.join("/workspace", contentRecord.projectDir), undefined, undefined, );