diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2aa038f26e..ed0bb88843 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -13,6 +13,9 @@ jobs: extension-contract-tests: uses: ./.github/workflows/extension-contract-tests.yaml + positron-api-tests: + uses: ./.github/workflows/positron-api-tests.yaml + connect-contract-tests: uses: ./.github/workflows/connect-contract-tests.yaml @@ -34,7 +37,14 @@ jobs: # Slack notification on failure slack-notification: needs: - [vscode, extension-contract-tests, connect-contract-tests, package, e2e] + [ + vscode, + extension-contract-tests, + positron-api-tests, + connect-contract-tests, + package, + e2e, + ] if: failure() runs-on: ubuntu-latest steps: @@ -54,7 +64,14 @@ jobs: # Slack notification when CI recovers from failure slack-notification-resolved: needs: - [vscode, extension-contract-tests, connect-contract-tests, package, e2e] + [ + vscode, + extension-contract-tests, + positron-api-tests, + connect-contract-tests, + package, + e2e, + ] if: success() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/positron-api-tests.yaml b/.github/workflows/positron-api-tests.yaml new file mode 100644 index 0000000000..77606c91c5 --- /dev/null +++ b/.github/workflows/positron-api-tests.yaml @@ -0,0 +1,45 @@ +name: Positron-API-Tests +on: [workflow_call] +permissions: + contents: read +env: + POSITRON_CHANNEL: stable +jobs: + test: + # @posit-dev/positron-test-electron currently supports macOS only. + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "**/package-lock.json" + # The interpreter-discovery tests need Python and R installed so + # Positron's runtime discovery can find them. + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - uses: r-lib/actions/setup-r@v2 + with: + r-version: "4.4" + - id: get-date + run: echo "date=$(/bin/date -u "+%Y%m%d")" >> $GITHUB_OUTPUT + shell: bash + - uses: actions/cache/restore@v6 + id: cache + with: + path: ./extensions/vscode/.positron-test + key: positron-${{ env.POSITRON_CHANNEL }}-${{ steps.get-date.outputs.date }} + - run: npm ci --no-audit --no-fund + - name: Run Positron API tests + uses: posit-dev/setup-positron@main + with: + positron-channel: ${{ env.POSITRON_CHANNEL }} + working-directory: extensions/vscode + run: npm run test-positron + - uses: actions/cache/save@v6 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: ./extensions/vscode/.positron-test + key: positron-${{ env.POSITRON_CHANNEL }}-${{ steps.get-date.outputs.date }} diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index cbacd3ccaa..cb7a1efc2f 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -109,6 +109,11 @@ jobs: if: needs.detect-changes.outputs.has-code == 'true' uses: ./.github/workflows/extension-contract-tests.yaml + positron-api-tests: + needs: detect-changes + if: needs.detect-changes.outputs.has-code == 'true' + uses: ./.github/workflows/positron-api-tests.yaml + connect-contract-tests: needs: detect-changes if: needs.detect-changes.outputs.has-code == 'true' diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore index 0b60dfa12f..387ea74dd1 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -2,4 +2,5 @@ out dist node_modules .vscode-test/ +.positron-test/ *.vsix diff --git a/extensions/vscode/.vscode-test.mjs b/extensions/vscode/.vscode-test.mjs index 7de44a9c68..75d3bfa131 100644 --- a/extensions/vscode/.vscode-test.mjs +++ b/extensions/vscode/.vscode-test.mjs @@ -1,6 +1,8 @@ import { defineConfig } from "@vscode/test-cli"; export default defineConfig({ - files: "out/test/**/*.test.js", + // Only the plain VSCode suite; out/test/positron/ holds the Positron-only + // tests, which are run by `npm run test-positron` inside a Positron build. + files: "out/test/suite/**/*.test.js", workspaceFolder: `${import.meta.dirname}/../../../../test/sample-content/fastapi-simple`, }); diff --git a/extensions/vscode/CLAUDE.md b/extensions/vscode/CLAUDE.md index 50b5368f27..724299c9ee 100644 --- a/extensions/vscode/CLAUDE.md +++ b/extensions/vscode/CLAUDE.md @@ -45,9 +45,12 @@ npm run test-unit # Run only Mocha integration tests (opens VSCode test instance) npm test + +# Run Positron API integration tests (downloads a Positron build; macOS only) +npm run test-positron ``` -Unit tests are in `src/**/*.test.ts` (excluding `src/test/`). Integration tests using VSCode APIs are in `src/test/`. +Unit tests are in `src/**/*.test.ts` (excluding `src/test/`). Integration tests using VSCode APIs are in `src/test/suite/`. Positron-only integration tests, which run inside a real Positron build, are in `src/test/positron/` (see its README). # Architecture diff --git a/extensions/vscode/esbuild.tests.mjs b/extensions/vscode/esbuild.tests.mjs index 4fc13e7dd6..ee69ba6452 100644 --- a/extensions/vscode/esbuild.tests.mjs +++ b/extensions/vscode/esbuild.tests.mjs @@ -2,14 +2,22 @@ import * as esbuild from "esbuild"; async function buildTests() { const ctx = await esbuild.context({ - entryPoints: ["src/test/**/*.test.ts"], + // index.ts is the Mocha entry point for the Positron-only suite; it is + // loaded by @posit-dev/positron-test-electron (see + // scripts/run-positron-tests.mjs). + entryPoints: ["src/test/**/*.test.ts", "src/test/positron/index.ts"], bundle: true, format: "cjs", minify: false, sourcemap: true, platform: "node", outdir: "out/test", - external: ["vscode"], + // Pin the output layout so out/test mirrors src/test (suite/, positron/) + // regardless of which entry points exist. + outbase: "src/test", + // mocha is resolved from node_modules at runtime by the Positron suite's + // entry point; bundling it breaks its dynamic requires. + external: ["vscode", "mocha"], tsconfig: "tsconfig.test.json", }); diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index dd7c564e5d..b733f6e14a 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -656,6 +656,8 @@ "esbuild-tests": "node ./esbuild.tests.mjs", "pretest": "npm run esbuild-tests && npm run esbuild-base", "test": "vscode-test", + "pretest-positron": "npm run esbuild-tests && npm run esbuild-base", + "test-positron": "node ./scripts/run-positron-tests.mjs", "test-unit": "vitest run", "test-integration-interpreters": "vitest run src/interpreters/integration.test.ts", "test-integration-inspect": "vitest run src/inspect/integration.test.ts", @@ -664,6 +666,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@posit-dev/mock-connect": "*", + "@posit-dev/positron-test-electron": "^0.0.2", "@types/jsonwebtoken": "^9.0.10", "@types/mocha": "^10.0.2", "@types/node": "^22.0.0", diff --git a/extensions/vscode/scripts/run-positron-tests.mjs b/extensions/vscode/scripts/run-positron-tests.mjs new file mode 100644 index 0000000000..29f03d2772 --- /dev/null +++ b/extensions/vscode/scripts/run-positron-tests.mjs @@ -0,0 +1,71 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Launcher for the Positron-only integration tests (src/test/positron/). +// +// Downloads (or reuses a cached) Positron build and runs the compiled Mocha +// entry point (out/test/positron/index.js) inside it, via +// @posit-dev/positron-test-electron. +// +// Run with `npm run test-positron` (which builds the extension and tests +// first). Set POSITRON_CHANNEL=daily to test against a daily Positron build +// (default: stable). +// +// NOTE: the released @posit-dev/positron-test-electron supports macOS only; +// Windows/Linux support has landed upstream and is pending an npm release. + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runTests } from "@posit-dev/positron-test-electron"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + // Extension root (contains package.json); scripts/ lives one level below it. + const extensionDevelopmentPath = path.resolve(__dirname, ".."); + + // Compiled Mocha entry point that discovers and runs the Positron tests. + const extensionTestsPath = path.resolve( + extensionDevelopmentPath, + "out", + "test", + "positron", + "index.js", + ); + + // Publisher activates on workspaceContains:/ — open the same sample project + // the plain VSCode suite uses (.vscode-test.mjs). + const workspacePath = path.resolve( + extensionDevelopmentPath, + "..", + "..", + "test", + "sample-content", + "fastapi-simple", + ); + + const code = await runTests({ + channel: process.env.POSITRON_CHANNEL === "daily" ? "daily" : "stable", + extensionDevelopmentPath, + extensionTestsPath, + // The interpreter-discovery tests need Positron's bundled runtime + // extensions (Python, Ark/R) to register language runtimes, so opt out of + // the default --disable-extensions. Copilot is bundled too and spams the + // logs with failed GitHub auth attempts on credential-less machines, so + // disable it individually. + disableExtensions: false, + launchArgs: [ + workspacePath, + "--disable-workspace-trust", + "--disable-extension", + "GitHub.copilot-chat", + ], + }); + + process.exit(code); +} + +main().catch((err) => { + console.error("Failed to run Positron integration tests:"); + console.error(err); + process.exit(1); +}); diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index 242c0eaa2a..eed0b1d297 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -716,7 +716,10 @@ describe("scanRPackages (real R + renv)", async () => { describe( "scanPythonDependencies (real Python)", - { timeout: 30_000 }, + // Scanning spawns a real Python that enumerates installed packages, which + // can approach 30s on its own under parallel suite load; match the ceiling + // used by the scanRPackages block above. + { timeout: 120_000 }, async () => { const python3Available = await isExecutableAvailable("python3"); const pythonAvailable = diff --git a/extensions/vscode/src/test/positron/README.md b/extensions/vscode/src/test/positron/README.md new file mode 100644 index 0000000000..764bc0b2b7 --- /dev/null +++ b/extensions/vscode/src/test/positron/README.md @@ -0,0 +1,61 @@ +# Positron API Tests + +Integration tests that run the Publisher extension inside a real +[Positron](https://positron.posit.co/) build and exercise its use of the +Positron API — code paths that the plain VSCode suite (`src/test/suite/`) and +the mock-based contract tests (`test/extension-contract-tests/`) can't reach. + +Part of the rollout tracked in +[posit-dev/positron#14531](https://github.com/posit-dev/positron/issues/14531). + +## How it works + +- `scripts/run-positron-tests.mjs` uses + [`@posit-dev/positron-test-electron`](https://github.com/posit-dev/positron-test-electron) + to download (and cache, under `.positron-test/`) a Positron build, then runs + the compiled Mocha entry point (`out/test/positron/index.js`) inside its + extension host — the Positron analog of `@vscode/test-electron`. +- `index.ts` is that entry point: it discovers `*.test.js` files in this + directory and runs them with Mocha (tdd UI). +- Tests are compiled by `esbuild.tests.mjs` along with the plain suite; the + two are kept apart by directory (`out/test/suite/` vs `out/test/positron/`). +- Positron's bundled extensions are left enabled (no `--disable-extensions`) + because runtime discovery — which Publisher's interpreter resolution relies + on — is provided by the bundled Python and Ark (R) extensions. + +## Running locally + +```bash +npm run test-positron # against the latest stable Positron +POSITRON_CHANNEL=daily npm run test-positron # against a daily build +``` + +> **Note:** the released `@posit-dev/positron-test-electron` supports **macOS +> only**; Windows/Linux support has landed upstream +> ([posit-dev/positron-test-electron#3](https://github.com/posit-dev/positron-test-electron/issues/3)) +> and is pending an npm release. Until then, on other platforms rely on the +> `Positron-API-Tests` GitHub Actions workflow +> (`.github/workflows/positron-api-tests.yaml`), which runs on every PR and +> push to `main`. + +The interpreter-discovery tests expect a Python and an R installation that +Positron can discover on the machine. + +## Adding tests + +Add a `.test.ts` file in this directory using Mocha's tdd UI +(`suite`/`test`). Things to know: + +- The Positron API is reached through the `acquirePositronApi()` global that + Positron injects into the extension host (typed by + `src/@types/positron.d.ts`). Publisher's own code feature-detects Positron + the same way (`src/utils/vscode.ts`). +- Prefer testing Publisher's real behavior at the API boundary: import the + extension source (e.g. `import { ... } from "src/utils/vscode"`) and assert + on what it sends to / receives from the live API. +- Runtime discovery is asynchronous and can be slow on cold CI machines; wait + for Positron to report a runtime before asserting on code that depends on + one (see `waitForPreferredRuntime` in `interpreter-discovery.test.ts`). +- Keep tests independent of a live kernel actually starting whenever possible + — metadata-level assertions (`getPreferredRuntime`) are much faster and less + flaky than session-level ones. diff --git a/extensions/vscode/src/test/positron/extension.test.ts b/extensions/vscode/src/test/positron/extension.test.ts new file mode 100644 index 0000000000..29398d4171 --- /dev/null +++ b/extensions/vscode/src/test/positron/extension.test.ts @@ -0,0 +1,40 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Positron-only integration test. +// +// Sanity checks for the contract Publisher depends on when running inside +// Positron: the extension host injects an `acquirePositronApi` global +// (Publisher feature-detects Positron by calling it — see +// src/utils/vscode.ts), and the Publisher extension activates. + +import * as assert from "assert"; +import { extensions } from "vscode"; + +suite("Positron: extension host", () => { + test("Positron injects the acquirePositronApi global", () => { + assert.strictEqual( + typeof acquirePositronApi, + "function", + "the extension host should provide the acquirePositronApi global", + ); + + const api = acquirePositronApi(); + assert.ok(api, "acquirePositronApi() should return the Positron API"); + assert.strictEqual(typeof api.version, "string"); + assert.ok( + api.version.length > 0, + "the Positron API should report a version", + ); + }); + + test("Publisher activates in Positron", async () => { + const publisher = extensions.getExtension("posit.publisher"); + assert.ok( + publisher, + "posit.publisher should be present in the extension host", + ); + + await publisher.activate(); + assert.ok(publisher.isActive, "Publisher should activate without error"); + }); +}); diff --git a/extensions/vscode/src/test/positron/index.ts b/extensions/vscode/src/test/positron/index.ts new file mode 100644 index 0000000000..c1ef185efb --- /dev/null +++ b/extensions/vscode/src/test/positron/index.ts @@ -0,0 +1,49 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Mocha entry point for the Positron-only integration tests. This module is +// loaded inside the Positron extension host by +// @posit-dev/positron-test-electron (see scripts/run-positron-tests.mjs), +// which requires it and calls run(). +// +// These tests are kept separate from the plain VSCode suite (src/test/suite/) +// because they exercise the Positron API, which is only available when the +// tests run inside Positron rather than vanilla VSCode. + +import * as fs from "fs"; +import * as path from "path"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true, + // Runtime discovery on a cold CI machine is slow, so give each test a + // generous ceiling. + timeout: 180000, + }); + + const testsRoot = __dirname; + const files = fs.readdirSync(testsRoot, { + recursive: true, + encoding: "utf-8", + }); + for (const file of files) { + if (file.endsWith(".test.js")) { + mocha.addFile(path.resolve(testsRoot, file)); + } + } + + return new Promise((resolve, reject) => { + try { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} test(s) failed.`)); + } else { + resolve(); + } + }); + } catch (err) { + reject(err); + } + }); +} diff --git a/extensions/vscode/src/test/positron/interpreter-discovery.test.ts b/extensions/vscode/src/test/positron/interpreter-discovery.test.ts new file mode 100644 index 0000000000..77d271cd07 --- /dev/null +++ b/extensions/vscode/src/test/positron/interpreter-discovery.test.ts @@ -0,0 +1,88 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Positron-only integration test. +// +// Publisher's Positron integration is interpreter discovery: +// getPythonInterpreterPath() and getRInterpreterPath() (src/utils/vscode.ts) +// ask Positron for the preferred runtime via +// positron.runtime.getPreferredRuntime() before falling back to the VSCode +// mechanisms. These tests run inside a real Positron build and assert that +// Publisher resolves the same interpreter Positron itself reports — covering +// the API acquisition, the retry loop, and the `~/` expansion of runtime +// paths, none of which is reachable in vanilla VSCode. + +import * as assert from "assert"; +import os from "node:os"; +import path from "node:path"; +import { LanguageRuntimeMetadata } from "positron"; +import { + getPythonInterpreterPath, + getRInterpreterPath, +} from "src/utils/vscode"; + +/** + * Runtime discovery starts when Positron boots and can take a while on a cold + * CI machine. Wait until Positron itself reports a preferred runtime before + * exercising Publisher's discovery, so the tests measure Publisher's behavior + * rather than discovery timing. + */ +async function waitForPreferredRuntime( + languageId: string, +): Promise { + const api = acquirePositronApi(); + const deadline = Date.now() + 120000; + let lastFailure = "getPreferredRuntime was never attempted"; + + while (Date.now() < deadline) { + try { + const runtime = await api.runtime.getPreferredRuntime(languageId); + if (runtime) { + return runtime; + } + lastFailure = "Positron resolved without reporting a preferred runtime"; + } catch (error: unknown) { + lastFailure = `getPreferredRuntime threw: ${String(error)}`; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + return assert.fail( + `Positron did not report a preferred ${languageId} runtime before the ` + + `deadline; is a ${languageId} interpreter installed? ${lastFailure}`, + ); +} + +/** + * Older Positron builds report runtime paths with a leading `~/` + * (https://github.com/posit-dev/positron/issues/12942 — fixed in July 2026 + * releases), and Publisher expands them before use to stay compatible with + * those builds. Mirror that expansion so the expected value matches + * Publisher's output either way; drop this helper when the expansion leaves + * src/utils/vscode.ts. + */ +function expandTilde(runtimePath: string): string { + return runtimePath.startsWith("~/") + ? path.join(os.homedir(), runtimePath.slice(1)) + : runtimePath; +} + +suite("Positron: interpreter discovery", () => { + test("resolves the Python interpreter from Positron's preferred runtime", async () => { + const runtime = await waitForPreferredRuntime("python"); + + const python = await getPythonInterpreterPath(); + assert.ok( + python, + "Publisher should resolve a Python interpreter in Positron", + ); + assert.strictEqual(python.pythonPath, expandTilde(runtime.runtimePath)); + }); + + test("resolves the R interpreter from Positron's preferred runtime", async () => { + const runtime = await waitForPreferredRuntime("r"); + + const r = await getRInterpreterPath(); + assert.ok(r, "Publisher should resolve an R interpreter in Positron"); + assert.strictEqual(r.rPath, expandTilde(runtime.runtimePath)); + }); +}); diff --git a/package-lock.json b/package-lock.json index 930cafad52..855351ddcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@posit-dev/mock-connect": "*", + "@posit-dev/positron-test-electron": "^0.0.2", "@types/jsonwebtoken": "^9.0.10", "@types/mocha": "^10.0.2", "@types/node": "^22.0.0", @@ -2419,6 +2420,38 @@ "resolved": "test/mock-connect", "link": true }, + "node_modules/@posit-dev/positron-test-electron": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@posit-dev/positron-test-electron/-/positron-test-electron-0.0.2.tgz", + "integrity": "sha512-betE+lg9R6qom5cHafmimF9bJ9yWUhATj9JxzRNDAPCh9CLisQ8iSxfIv+RL+x+r7frGT0H018Y7ekb4JxW2vA==", + "dev": true, + "dependencies": { + "@vscode/test-electron": "^2.4.1" + }, + "bin": { + "positron-test-electron": "out/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@posit-dev/positron-test-electron/node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",