diff --git a/.github/workflows/test-positron.yaml b/.github/workflows/test-positron.yaml new file mode 100644 index 00000000..9e9008c4 --- /dev/null +++ b/.github/workflows/test-positron.yaml @@ -0,0 +1,34 @@ +name: Test Positron API + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + test-positron: + runs-on: macos-latest + name: Test Positron API + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: latest + - uses: quarto-dev/quarto-actions/setup@v2 + - name: Build vscode extension + # macos-latest runners have ~7 GB RAM, so Node's default V8 heap limit + # (~2 GB) is too small for the vscode-editor vite build and it OOMs. + # Raise the limit to match what larger (ubuntu) runners get by default. + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: | + yarn install + yarn run build-vscode + - name: Run Positron API tests + uses: posit-dev/setup-positron@main + with: + positron-channel: stable diff --git a/.gitignore b/.gitignore index d87a955e..695d00b2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ public/dist .ipynb_checkpoints /.luarc.json .vscode-test +.positron-test *.vsix *.timestamp-*.mjs diff --git a/apps/vscode/build.ts b/apps/vscode/build.ts index 61777686..90e05b11 100644 --- a/apps/vscode/build.ts +++ b/apps/vscode/build.ts @@ -19,7 +19,7 @@ import * as glob from "glob"; const args = process.argv; const dev = args[2] === "dev"; const test = args[2] === "test"; -const testFiles = glob.sync("src/test/{*.ts,fixtures/*.ts,utils/*.ts}"); +const testFiles = glob.sync("src/test/{*.ts,fixtures/*.ts,utils/*.ts,positron/*.ts}"); const testBuildOptions = { entryPoints: testFiles, diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 5a8c32eb..71f587ac 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1497,7 +1497,8 @@ "lint": "eslint src --ext ts", "build-lang": "node syntaxes/build-lang", "build-test": "yarn run build test", - "test": "yarn build-test && vscode-test" + "test": "yarn build-test && vscode-test", + "test-positron": "yarn build-test && node ./scripts/run-positron-tests.mjs" }, "dependencies": { "axios": "^1.16.0", @@ -1545,6 +1546,7 @@ "@types/uuid": "^9.0.0", "@types/vscode": "1.75.0", "@types/which": "^2.0.2", + "@posit-dev/positron-test-electron": "^0.0.2", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "@vscode/test-cli": "^0.0.11", diff --git a/apps/vscode/scripts/run-positron-tests.mjs b/apps/vscode/scripts/run-positron-tests.mjs new file mode 100644 index 00000000..b36f183e --- /dev/null +++ b/apps/vscode/scripts/run-positron-tests.mjs @@ -0,0 +1,57 @@ +/* + * run-positron-tests.mjs + * + * Launcher for the Positron-only integration tests. Downloads (or reuses a + * cached) Positron build and runs the compiled Mocha entry point + * (`test-out/positron/index.js`) inside it, via `@posit-dev/positron-test-electron`. + * + * Run with: `yarn test-positron` (which builds the tests first). + * + * Following the VS Code extension testing pattern, the tests run with + * `--disable-extensions` (applied by the harness) so the Quarto extension is + * exercised in isolation. Positron's own API global and bundled extensions + * (language runtimes, notebook export) remain available. + * + * Set POSITRON_CHANNEL=daily to test against a daily build (default: stable). + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +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, + "test-out", + "positron", + "index.js" + ); + + const code = await runTests({ + channel: process.env.POSITRON_CHANNEL === "daily" ? "daily" : "stable", + extensionDevelopmentPath, + extensionTestsPath, + // Our tests exercise Positron's bundled extensions (notebook export and the + // R/Python runtimes), so opt out of the default `--disable-extensions`. + // Extension auto-update (which would otherwise evict the extension loaded + // from extensionDevelopmentPath) is disabled by @posit-dev/positron-test- + // electron itself, so no extra launch args are needed here. + disableExtensions: false, + }); + + process.exit(code); +} + +main().catch((err) => { + console.error("Failed to run Positron integration tests:"); + console.error(err); + process.exit(1); +}); diff --git a/apps/vscode/src/test/positron/execute-cell.test.ts b/apps/vscode/src/test/positron/execute-cell.test.ts new file mode 100644 index 00000000..16e6d3db --- /dev/null +++ b/apps/vscode/src/test/positron/execute-cell.test.ts @@ -0,0 +1,203 @@ +/* + * execute-cell.test.ts + * + * Positron-only integration test. + * + * Verifies the Quarto-specific behavior that runs on the way to the Positron + * runtime API. In Positron the cell executor delegates to + * `positron.runtime.executeCode` (`src/host/positron.ts`); this suite asserts + * that Quarto does the right transformations before that call: + * - it strips Quarto cell options (`#| ...`) from the executed code, and + * - it reroutes knitr Python cells through `reticulate::repl_python(...)` and + * submits them to the *R* runtime. + * Neither of these paths exists in vanilla VS Code, so they are uncovered by + * the main suite. + * + * We stub Positron's API global with a spy and assert on the call the extension + * makes, rather than depending on a live kernel actually starting (which hinges + * on the host machine having a resolvable interpreter). The extension + * re-acquires the API inside `execute()` via `tryAcquirePositronApi()`, which + * reads `globalThis.acquirePositronApi` on every call, so a stub installed + * after activation is observed by the run. + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { extension } from "../extension"; +import { tryAcquirePositronApi } from "@posit-dev/positron"; + +interface RuntimeCall { + method: "executeCode" | "executeInlineCell"; + args: unknown[]; +} + +suite("Positron: cell execution", function () { + this.timeout(30000); + + // `acquirePositronApi` is injected onto the global by Positron; we swap it for + // a spy during each test and must restore it afterwards. + const globalWithApi = globalThis as { acquirePositronApi?: () => unknown }; + let originalAcquire: (() => unknown) | undefined; + + teardown(function () { + if (originalAcquire !== undefined) { + globalWithApi.acquirePositronApi = originalAcquire; + originalAcquire = undefined; + } + }); + + /** + * Activate Quarto, install a spy over the Positron runtime, open `qmd` as a + * Quarto document, put the cursor on `codeLine`, run the current cell, and + * return the runtime calls the extension made. + */ + async function runCellAndCaptureCalls( + qmd: string, + codeLine: number + ): Promise { + const positron = tryAcquirePositronApi(); + assert.ok( + positron, + "Positron API should be available when running under positron-test-electron" + ); + + // Activate with the real API present so Quarto selects the Positron host. + const quarto = extension(); + if (!quarto.isActive) { + await quarto.activate(); + } + + // Spy over the runtime: forward everything except the execution methods, + // which we record instead of dispatching to a kernel. + const calls: RuntimeCall[] = []; + originalAcquire = globalWithApi.acquirePositronApi; + const realApi = originalAcquire!() as { runtime: Record }; + const fakeRuntime = new Proxy(realApi.runtime, { + get(target, prop, receiver) { + if (prop === "executeCode") { + return (...args: unknown[]) => { + calls.push({ method: "executeCode", args }); + return Promise.resolve(); + }; + } + if (prop === "executeInlineCell") { + return (...args: unknown[]) => { + calls.push({ method: "executeInlineCell", args }); + return Promise.resolve(); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const fakeApi = new Proxy(realApi, { + get(target, prop, receiver) { + if (prop === "runtime") { + return fakeRuntime; + } + return Reflect.get(target, prop, receiver); + }, + }); + globalWithApi.acquirePositronApi = () => fakeApi; + + const doc = await vscode.workspace.openTextDocument({ + language: "quarto", + content: qmd, + }); + const editor = await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(codeLine, 0, codeLine, 0); + + await vscode.commands.executeCommand("quarto.runCurrentCell"); + return calls; + } + + test("submits a Python cell to executeCode as python, with cell options stripped", async function () { + const marker = `qmd_marker_${Date.now()}`; + const qmd = [ + "---", + "title: test", + "---", + "", + "```{python}", + "#| echo: false", + `${marker} = 42`, + "```", + "", + ].join("\n"); + // Cursor on the statement (line 6), i.e. below the `#|` option line. + const calls = await runCellAndCaptureCalls(qmd, 6); + + const call = calls.find( + (c) => c.method === "executeCode" && c.args[0] === "python" + ); + assert.ok( + call, + "Running the cell should call positron.runtime.executeCode('python', ...). " + + `Observed: ${describe(calls)}` + ); + + const code = String(call!.args[1]); + assert.ok( + code.includes(`${marker} = 42`), + "the cell's code should be forwarded to the runtime" + ); + assert.ok( + !code.includes("#|"), + "Quarto cell options (`#| ...`) should be stripped before execution" + ); + }); + + test("reroutes a knitr Python cell through reticulate and submits it as r", async function () { + const marker = `qmd_marker_${Date.now()}`; + const qmd = [ + "---", + "engine: knitr", + "---", + "", + "```{python}", + `${marker} = 42`, + "```", + "", + ].join("\n"); + // Cursor on the statement (line 5). + const calls = await runCellAndCaptureCalls(qmd, 5); + + // In a knitr document, Quarto sends Python to the R runtime wrapped in + // reticulate rather than to the Python runtime directly. + const call = calls.find( + (c) => c.method === "executeCode" && c.args[0] === "r" + ); + assert.ok( + call, + "A knitr Python cell should be submitted to executeCode('r', ...). " + + `Observed: ${describe(calls)}` + ); + + const code = String(call!.args[1]); + assert.ok( + code.includes("reticulate::repl_python"), + "knitr Python should be wrapped in reticulate::repl_python(...)" + ); + assert.ok( + code.includes(`${marker} = 42`), + "the original Python code should be embedded in the reticulate call" + ); + }); +}); + +/** Compact summary of observed runtime calls for assertion messages. */ +function describe(calls: RuntimeCall[]): string { + return JSON.stringify( + calls.map((c) => ({ method: c.method, language: c.args[0] })) + ); +} diff --git a/apps/vscode/src/test/positron/index.ts b/apps/vscode/src/test/positron/index.ts new file mode 100644 index 00000000..fb7bb406 --- /dev/null +++ b/apps/vscode/src/test/positron/index.ts @@ -0,0 +1,54 @@ +/* + * index.ts + * + * 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 main `@vscode/test-cli` suite because + * they exercise the Positron API, which is only available when the tests run + * inside Positron rather than vanilla VS Code. + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import * as path from "path"; +import * as glob from "glob"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true, + // Runtime start-up and cross-process execution are slower than the plain + // VS Code suite, so give each test a generous ceiling. + timeout: 120000, + }); + + const testsRoot = __dirname; + const files = glob.sync("**/*.test.js", { cwd: testsRoot }); + files.forEach((file) => 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/apps/vscode/src/test/positron/notebook-export.test.ts b/apps/vscode/src/test/positron/notebook-export.test.ts new file mode 100644 index 00000000..1afb34f8 --- /dev/null +++ b/apps/vscode/src/test/positron/notebook-export.test.ts @@ -0,0 +1,87 @@ +/* + * notebook-export.test.ts + * + * Positron-only integration test. + * + * Verifies that Quarto registers its notebook exporter with Positron's + * `positron.notebook-export` extension API. This is pure cross-extension wiring + * that only exists in Positron (`src/providers/notebook-export.ts`) and is not + * exercised by the vanilla VS Code suite, where the `positron.notebook-export` + * extension is absent. + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { extension } from "../extension"; +import { notebookExporterLabel } from "../../providers/notebook-export"; +import { NotebookExportExtension } from "../../@types/positron-notebook-export"; + +const kNotebookExportExtensionId = "positron.notebook-export"; + +suite("Positron: notebook export", function () { + test("registers the Quarto Markdown exporter with positron.notebook-export", async function () { + this.timeout(30000); + + const exportExt = vscode.extensions.getExtension( + kNotebookExportExtensionId + ); + assert.ok( + exportExt, + `Expected the built-in ${kNotebookExportExtensionId} extension to be present in Positron` + ); + + const exportApi = await exportExt.activate(); + + // Activating Quarto is what registers the exporter against the notebook + // export API. + const quarto = extension(); + if (!quarto.isActive) { + await quarto.activate(); + } + + // Quarto registers the exporter asynchronously (after the notebook-export + // extension finishes activating), so poll until it shows up rather than + // asserting immediately. + const quartoExporter = await waitFor( + () => exportApi.exporters.find((e) => e.label === notebookExporterLabel), + 15000 + ); + + assert.ok( + quartoExporter, + `Expected an exporter labelled "${notebookExporterLabel}" to be registered` + ); + assert.strictEqual( + quartoExporter.fileExtension, + ".qmd", + "Quarto exporter should target the .qmd file extension" + ); + }); +}); + +/** Poll `fn` until it returns a truthy value or `timeoutMs` elapses. */ +async function waitFor( + fn: () => T | undefined, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = fn(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return fn(); +} diff --git a/package.json b/package.json index 0c7c4827..20fb6dee 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev-vscode": "turbo run dev --filter quarto...", "build-vscode": "turbo run build --filter quarto...", "test-vscode": "cd apps/vscode && yarn test", + "test-positron": "cd apps/vscode && yarn test-positron", "install-vscode": "cd apps/vscode && yarn install-vscode", "install-positron": "cd apps/vscode && yarn install-positron", "lint": "turbo run lint", diff --git a/yarn.lock b/yarn.lock index 6155cb24..d3bfd3a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2064,6 +2064,13 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@posit-dev/positron-test-electron@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@posit-dev/positron-test-electron/-/positron-test-electron-0.0.2.tgz#509f1b56cb92342bdf0b2a8e707e4919ed2ad313" + integrity sha512-betE+lg9R6qom5cHafmimF9bJ9yWUhATj9JxzRNDAPCh9CLisQ8iSxfIv+RL+x+r7frGT0H018Y7ekb4JxW2vA== + dependencies: + "@vscode/test-electron" "^2.4.1" + "@posit-dev/positron@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@posit-dev/positron/-/positron-0.2.4.tgz#7cbd2601963f4df49b45f58815c7db721c7118b6" @@ -2794,7 +2801,7 @@ supports-color "^9.4.0" yargs "^17.7.2" -"@vscode/test-electron@^2.5.2": +"@vscode/test-electron@^2.4.1", "@vscode/test-electron@^2.5.2": version "2.5.2" resolved "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz" integrity sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg== @@ -2873,10 +2880,10 @@ acorn-walk@^8.1.1, acorn-walk@^8.2.0: dependencies: acorn "^8.11.0" -acorn@8.16.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" - integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +acorn@8.17.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" + integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0: version "7.4.1" @@ -8682,7 +8689,16 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8755,7 +8771,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9736,7 +9759,16 @@ workerpool@^9.2.0: resolved "https://registry.npmjs.org/workerpool/-/workerpool-9.3.3.tgz" integrity sha512-slxCaKbYjEdFT/o2rH9xS1hf4uRDch1w7Uo+apxhZ+sf/1d9e0ZVkn42kPNGP2dgjIx6YFvSevj0zHvbWe2jdw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==