From 29e89b91eb420b4efc0646e1f5eab590f3358a3d Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 10:12:37 -0400 Subject: [PATCH 1/8] add tests --- .gitignore | 1 + apps/vscode/build.ts | 2 +- apps/vscode/package.json | 4 +- apps/vscode/scripts/run-positron-tests.mjs | 54 +++++ .../src/test/positron/execute-cell.test.ts | 203 ++++++++++++++++++ apps/vscode/src/test/positron/index.ts | 54 +++++ .../src/test/positron/notebook-export.test.ts | 87 ++++++++ yarn.lock | 50 ++++- 8 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 apps/vscode/scripts/run-positron-tests.mjs create mode 100644 apps/vscode/src/test/positron/execute-cell.test.ts create mode 100644 apps/vscode/src/test/positron/index.ts create mode 100644 apps/vscode/src/test/positron/notebook-export.test.ts diff --git a/.gitignore b/.gitignore index d87a955ed..695d00b2c 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 617776868..90e05b11b 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 5a8c32eb4..08827b0c1 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": "latest", "@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 000000000..44d501748 --- /dev/null +++ b/apps/vscode/scripts/run-positron-tests.mjs @@ -0,0 +1,54 @@ +/* + * 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`. + 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 000000000..16e6d3db4 --- /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 000000000..fb7bb4064 --- /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 000000000..1afb34f8c --- /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/yarn.lock b/yarn.lock index 6155cb248..a11bb89b5 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@latest": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@posit-dev/positron-test-electron/-/positron-test-electron-0.0.1.tgz#76c045fa186e67899c5ec179d86c92de01a7dfbd" + integrity sha512-qN3KkagyRPvIVzF/T6K9T0hHJ3x6QUAbVBw07T6AGA8KGyAVzTFn3gtsLPc38uA3dJAqBDyNLtZuj7aYHaXNDQ== + 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,20 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" +<<<<<<< HEAD "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@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +>>>>>>> 12df022f (add tests) 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 +8775,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 +9763,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== From a83632a4b5e2b996d9cab72d68e13f8ce8a130af Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 11:18:02 -0400 Subject: [PATCH 2/8] use placeholder test workflow --- .github/workflows/test-positron.yaml | 29 ++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/test-positron.yaml diff --git a/.github/workflows/test-positron.yaml b/.github/workflows/test-positron.yaml new file mode 100644 index 000000000..8abef2d73 --- /dev/null +++ b/.github/workflows/test-positron.yaml @@ -0,0 +1,29 @@ +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 + 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/package.json b/package.json index 0c7c4827c..20fb6dee3 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", From 3302c12fb90fefca7d09fa7c79751bb7c30ce2c1 Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 11:31:55 -0400 Subject: [PATCH 3/8] Raise Node heap limit for Positron test build on macOS runner The macos-latest runner has ~7 GB RAM, so Node's default V8 old-space heap limit (~2 GB) is too small for the vscode-editor vite build, which OOMs during 'yarn run build-vscode'. The same build passes on ubuntu runners (16 GB RAM, ~4 GB default heap). Set NODE_OPTIONS to match. --- .github/workflows/test-positron.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-positron.yaml b/.github/workflows/test-positron.yaml index 8abef2d73..9e9008c4f 100644 --- a/.github/workflows/test-positron.yaml +++ b/.github/workflows/test-positron.yaml @@ -20,6 +20,11 @@ jobs: 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 From ac22d1c6a15ad9791acf507bf89ed5764a353e56 Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 11:47:00 -0400 Subject: [PATCH 4/8] Disable extension auto-update in Positron test host Running with extensions enabled (disableExtensions: false) keeps the gallery active, so Positron auto-updates 'outdated' extensions on startup and disables/removes the development Quarto extension loaded via extensionDevelopmentPath. By test time getExtension('quarto.quarto') returns undefined ('Extension quarto.quarto not found'). Seed a throwaway user-data-dir with extensions.autoUpdate / autoCheckUpdates set to false and pass it via launchArgs. positron-test- electron appends our launchArgs last, and Positron uses the last --user-data-dir, so our seeded settings win. --- apps/vscode/scripts/run-positron-tests.mjs | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/vscode/scripts/run-positron-tests.mjs b/apps/vscode/scripts/run-positron-tests.mjs index 44d501748..3448fe071 100644 --- a/apps/vscode/scripts/run-positron-tests.mjs +++ b/apps/vscode/scripts/run-positron-tests.mjs @@ -17,12 +17,45 @@ * Copyright (C) 2026 by Posit Software, PBC */ +import * as fs from "node:fs"; +import * as os from "node:os"; 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)); +/** + * Create a throwaway user-data-dir seeded with settings that turn off extension + * auto-update, and return `--user-data-dir ` launch args pointing at it. + * + * We run with extensions enabled (see `disableExtensions: false` below) so + * Positron's bundled extensions stay available, but that also leaves the + * extension gallery active. On startup Positron then auto-updates "outdated" + * extensions, and it treats the Quarto extension we load from + * `extensionDevelopmentPath` as one of them: it disables/removes it mid-run, so + * `vscode.extensions.getExtension("quarto.quarto")` is gone by the time the + * tests query it ("Extension quarto.quarto not found"). Disabling auto-update + * keeps our development extension in place. + * + * `@posit-dev/positron-test-electron` always passes its own `--user-data-dir` + * first and appends ours last; Positron uses the last occurrence, so ours wins. + * A short `os.tmpdir()` prefix keeps the derived IPC socket path under macOS's + * 103-char Unix-socket limit. + */ +function seededUserDataDirArgs() { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "qpt-")); + fs.mkdirSync(path.join(userDataDir, "User"), { recursive: true }); + fs.writeFileSync( + path.join(userDataDir, "User", "settings.json"), + JSON.stringify({ + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + }) + ); + return ["--user-data-dir", userDataDir]; +} + async function main() { // Extension root (contains package.json); `scripts/` lives one level below it. const extensionDevelopmentPath = path.resolve(__dirname, ".."); @@ -42,6 +75,7 @@ async function main() { // Our tests exercise Positron's bundled extensions (notebook export and the // R/Python runtimes), so opt out of the default `--disable-extensions`. disableExtensions: false, + launchArgs: seededUserDataDirArgs(), }); process.exit(code); From 16fd3c9bf69e27c067efb1b6ee936fcb2624e31c Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 15:23:58 -0400 Subject: [PATCH 5/8] update yarn.lock --- yarn.lock | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index a11bb89b5..8af79a82f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8689,9 +8689,6 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -<<<<<<< HEAD -"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" @@ -8701,8 +8698,7 @@ stop-iteration-iterator@^1.1.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: ->>>>>>> 12df022f (add tests) +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== From c5e439b32e38d238e2c9184511945bed2a088c5e Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Thu, 16 Jul 2026 16:27:14 -0400 Subject: [PATCH 6/8] Use positron-test-electron 0.0.2 and drop the runner's user-data-dir arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positron-test-electron 0.0.2 disables extension auto-update inside the test host it launches (seeding its own temp user-data-dir), which keeps the development Quarto extension from being evicted mid-run. Passing a second --user-data-dir from the runner (to seed the same setting ourselves) made Positron's getUserDataPath receive an array and crash with ERR_INVALID_ARG_TYPE, so drop it — 0.0.2 covers it in-package. Pin the dependency to ^0.0.2 (was floating "latest", which had locked to the fix-less 0.0.1) so CI installs the version with the fix. --- apps/vscode/package.json | 2 +- apps/vscode/scripts/run-positron-tests.mjs | 37 ++-------------------- yarn.lock | 8 ++--- 3 files changed, 8 insertions(+), 39 deletions(-) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 08827b0c1..71f587ac8 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1546,7 +1546,7 @@ "@types/uuid": "^9.0.0", "@types/vscode": "1.75.0", "@types/which": "^2.0.2", - "@posit-dev/positron-test-electron": "latest", + "@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 index 3448fe071..b36f183e7 100644 --- a/apps/vscode/scripts/run-positron-tests.mjs +++ b/apps/vscode/scripts/run-positron-tests.mjs @@ -17,45 +17,12 @@ * Copyright (C) 2026 by Posit Software, PBC */ -import * as fs from "node:fs"; -import * as os from "node:os"; 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)); -/** - * Create a throwaway user-data-dir seeded with settings that turn off extension - * auto-update, and return `--user-data-dir ` launch args pointing at it. - * - * We run with extensions enabled (see `disableExtensions: false` below) so - * Positron's bundled extensions stay available, but that also leaves the - * extension gallery active. On startup Positron then auto-updates "outdated" - * extensions, and it treats the Quarto extension we load from - * `extensionDevelopmentPath` as one of them: it disables/removes it mid-run, so - * `vscode.extensions.getExtension("quarto.quarto")` is gone by the time the - * tests query it ("Extension quarto.quarto not found"). Disabling auto-update - * keeps our development extension in place. - * - * `@posit-dev/positron-test-electron` always passes its own `--user-data-dir` - * first and appends ours last; Positron uses the last occurrence, so ours wins. - * A short `os.tmpdir()` prefix keeps the derived IPC socket path under macOS's - * 103-char Unix-socket limit. - */ -function seededUserDataDirArgs() { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "qpt-")); - fs.mkdirSync(path.join(userDataDir, "User"), { recursive: true }); - fs.writeFileSync( - path.join(userDataDir, "User", "settings.json"), - JSON.stringify({ - "extensions.autoUpdate": false, - "extensions.autoCheckUpdates": false, - }) - ); - return ["--user-data-dir", userDataDir]; -} - async function main() { // Extension root (contains package.json); `scripts/` lives one level below it. const extensionDevelopmentPath = path.resolve(__dirname, ".."); @@ -74,8 +41,10 @@ async function main() { 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, - launchArgs: seededUserDataDirArgs(), }); process.exit(code); diff --git a/yarn.lock b/yarn.lock index 8af79a82f..d3bfd3a3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2064,10 +2064,10 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@posit-dev/positron-test-electron@latest": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@posit-dev/positron-test-electron/-/positron-test-electron-0.0.1.tgz#76c045fa186e67899c5ec179d86c92de01a7dfbd" - integrity sha512-qN3KkagyRPvIVzF/T6K9T0hHJ3x6QUAbVBw07T6AGA8KGyAVzTFn3gtsLPc38uA3dJAqBDyNLtZuj7aYHaXNDQ== +"@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" From 1b39f406665774a15320dedf42524ac3fee437e6 Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Wed, 22 Jul 2026 17:42:23 -0400 Subject: [PATCH 7/8] update tests and docs --- .github/workflows/test-positron.yaml | 6 + apps/vscode/CONTRIBUTING.md | 8 + apps/vscode/scripts/run-positron-tests.mjs | 13 +- .../src/test/positron/execute-cell.test.ts | 157 +++++++++++++++++- .../src/test/positron/notebook-export.test.ts | 104 +++++++++--- claude.md | 1 + 6 files changed, 254 insertions(+), 35 deletions(-) diff --git a/.github/workflows/test-positron.yaml b/.github/workflows/test-positron.yaml index 9e9008c4f..7aff27619 100644 --- a/.github/workflows/test-positron.yaml +++ b/.github/workflows/test-positron.yaml @@ -4,9 +4,15 @@ on: push: branches: - main + paths: + - "apps/vscode/**" + - ".github/workflows/test-positron.yaml" pull_request: branches: - main + paths: + - "apps/vscode/**" + - ".github/workflows/test-positron.yaml" workflow_dispatch: jobs: diff --git a/apps/vscode/CONTRIBUTING.md b/apps/vscode/CONTRIBUTING.md index 11c839bab..cb6eee4fd 100644 --- a/apps/vscode/CONTRIBUTING.md +++ b/apps/vscode/CONTRIBUTING.md @@ -13,6 +13,14 @@ Run the extension tests with: yarn test-vscode # compile the test files and run them with the vscode-test CLI ``` +The Positron-specific integration tests (`src/test/positron/`) exercise Positron API code paths that vanilla VS Code can't reach. They run inside a downloaded Positron build rather than VS Code: + +```sh +yarn test-positron # build the tests and run them inside Positron +``` + +Set `POSITRON_CHANNEL=daily` to test against a daily build (default: `stable`). + Install the dev version of the extension in VS Code or Positron with: ```sh diff --git a/apps/vscode/scripts/run-positron-tests.mjs b/apps/vscode/scripts/run-positron-tests.mjs index b36f183e7..7a2819d58 100644 --- a/apps/vscode/scripts/run-positron-tests.mjs +++ b/apps/vscode/scripts/run-positron-tests.mjs @@ -39,12 +39,15 @@ async function main() { 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. + // Our tests exercise Positron's bundled notebook-export and runtime + // extensions, so the blanket `--disable-extensions` default would remove + // the APIs under test. Keep extensions enabled, but explicitly disable + // unrelated AI/chat extensions that add startup noise and login prompts. disableExtensions: false, + launchArgs: [ + "--disable-extension=GitHub.copilot-chat", + "--disable-extension=positron.positron-assistant", + ], }); process.exit(code); diff --git a/apps/vscode/src/test/positron/execute-cell.test.ts b/apps/vscode/src/test/positron/execute-cell.test.ts index 16e6d3db4..6420c9c5e 100644 --- a/apps/vscode/src/test/positron/execute-cell.test.ts +++ b/apps/vscode/src/test/positron/execute-cell.test.ts @@ -33,9 +33,22 @@ */ import * as assert from "assert"; +import * as path from "path"; import * as vscode from "vscode"; import { extension } from "../extension"; import { tryAcquirePositronApi } from "@posit-dev/positron"; +import { kInlineOutputEnabledSetting } from "../../host/positron"; + +const kDeprecatedInlineOutputEnabledSetting = + "positron.quarto.inlineOutput.enabled"; +const kExamplesOutDir = path.resolve( + __dirname, + "..", + "..", + "src", + "test", + "examples-out" +); interface RuntimeCall { method: "executeCode" | "executeInlineCell"; @@ -49,12 +62,31 @@ suite("Positron: cell execution", function () { // a spy during each test and must restore it afterwards. const globalWithApi = globalThis as { acquirePositronApi?: () => unknown }; let originalAcquire: (() => unknown) | undefined; + let originalGetConfiguration: typeof vscode.workspace.getConfiguration | undefined; + const tempFiles: vscode.Uri[] = []; - teardown(function () { + teardown(async function () { if (originalAcquire !== undefined) { globalWithApi.acquirePositronApi = originalAcquire; originalAcquire = undefined; } + + if (originalGetConfiguration !== undefined) { + (vscode.workspace as typeof vscode.workspace & { + getConfiguration: typeof vscode.workspace.getConfiguration; + }).getConfiguration = originalGetConfiguration; + originalGetConfiguration = undefined; + } + + while (tempFiles.length > 0) { + const tempFile = tempFiles.pop()!; + await vscode.workspace.fs.delete(tempFile).then( + () => undefined, + () => undefined + ); + } + + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); }); /** @@ -64,7 +96,8 @@ suite("Positron: cell execution", function () { */ async function runCellAndCaptureCalls( qmd: string, - codeLine: number + codeLine: number, + uri?: vscode.Uri ): Promise { const positron = tryAcquirePositronApi(); assert.ok( @@ -110,10 +143,12 @@ suite("Positron: cell execution", function () { }); globalWithApi.acquirePositronApi = () => fakeApi; - const doc = await vscode.workspace.openTextDocument({ - language: "quarto", - content: qmd, - }); + const doc = uri + ? await openSavedQuartoDocument(uri, qmd) + : await vscode.workspace.openTextDocument({ + language: "quarto", + content: qmd, + }); const editor = await vscode.window.showTextDocument(doc); editor.selection = new vscode.Selection(codeLine, 0, codeLine, 0); @@ -121,6 +156,53 @@ suite("Positron: cell execution", function () { return calls; } + async function openSavedQuartoDocument( + uri: vscode.Uri, + qmd: string + ): Promise { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(kExamplesOutDir)); + await vscode.workspace.fs.writeFile(uri, Buffer.from(qmd, "utf8")); + tempFiles.push(uri); + return await vscode.workspace.openTextDocument(uri); + } + + function makeTempQuartoUri(prefix: string): vscode.Uri { + return vscode.Uri.file( + path.join(kExamplesOutDir, `${prefix}-${Date.now()}.qmd`) + ); + } + + function mockInlineOutputEnabled(enabled: boolean): void { + originalGetConfiguration ??= vscode.workspace.getConfiguration; + (vscode.workspace as typeof vscode.workspace & { + getConfiguration: typeof vscode.workspace.getConfiguration; + }).getConfiguration = ((...args: Parameters) => { + const config = originalGetConfiguration!(...args); + return { + has: config.has.bind(config), + update: config.update.bind(config), + inspect: ((section: string) => { + if (section === kInlineOutputEnabledSetting) { + return { globalValue: enabled }; + } + if (section === kDeprecatedInlineOutputEnabledSetting) { + return { globalValue: undefined }; + } + return config.inspect(section); + }) as typeof config.inspect, + get: ((section: string, defaultValue?: unknown) => { + if ( + section === kInlineOutputEnabledSetting || + section === kDeprecatedInlineOutputEnabledSetting + ) { + return enabled; + } + return config.get(section, defaultValue); + }) as typeof config.get, + } as typeof config; + }) as typeof vscode.workspace.getConfiguration; + } + test("submits a Python cell to executeCode as python, with cell options stripped", async function () { const marker = `qmd_marker_${Date.now()}`; const qmd = [ @@ -135,7 +217,11 @@ suite("Positron: cell execution", function () { "", ].join("\n"); // Cursor on the statement (line 6), i.e. below the `#|` option line. - const calls = await runCellAndCaptureCalls(qmd, 6); + const calls = await runCellAndCaptureCalls( + qmd, + 6, + makeTempQuartoUri("positron-execute-code") + ); const call = calls.find( (c) => c.method === "executeCode" && c.args[0] === "python" @@ -170,7 +256,11 @@ suite("Positron: cell execution", function () { "", ].join("\n"); // Cursor on the statement (line 5). - const calls = await runCellAndCaptureCalls(qmd, 5); + const calls = await runCellAndCaptureCalls( + qmd, + 5, + makeTempQuartoUri("positron-knitr-python") + ); // In a knitr document, Quarto sends Python to the R runtime wrapped in // reticulate rather than to the Python runtime directly. @@ -193,6 +283,57 @@ suite("Positron: cell execution", function () { "the original Python code should be embedded in the reticulate call" ); }); + + test("uses executeInlineCell for a saved qmd when inline output is enabled", async function () { + const marker = `qmd_marker_${Date.now()}`; + const qmd = [ + "---", + "title: test", + "---", + "", + "```{python}", + "#| echo: false", + `${marker} = 42`, + "```", + "", + ].join("\n"); + + mockInlineOutputEnabled(true); + + const uri = makeTempQuartoUri("positron-inline"); + const calls = await runCellAndCaptureCalls(qmd, 6, uri); + + assert.ok( + !calls.some((c) => c.method === "executeCode"), + "Saved documents with inline output enabled should not fall back to executeCode" + ); + + const call = calls.find((c) => c.method === "executeInlineCell"); + assert.ok( + call, + "Running a saved document with inline output enabled should call positron.runtime.executeInlineCell(...). " + + `Observed: ${describe(calls)}` + ); + + assert.ok( + vscode.Uri.isUri(call!.args[0]) && + call!.args[0].toString() === uri.toString(), + "executeInlineCell should receive the saved document URI" + ); + + const ranges = call!.args[1] as vscode.Range[]; + assert.ok( + Array.isArray(ranges) && ranges.length === 1, + "executeInlineCell should receive the current cell range" + ); + + const metadata = call!.args[2] as Record[] | undefined; + assert.strictEqual( + metadata?.[0]?.echo, + false, + "executeInlineCell should receive parsed Quarto cell metadata" + ); + }); }); /** Compact summary of observed runtime calls for assertion messages. */ diff --git a/apps/vscode/src/test/positron/notebook-export.test.ts b/apps/vscode/src/test/positron/notebook-export.test.ts index 1afb34f8c..2637c76e6 100644 --- a/apps/vscode/src/test/positron/notebook-export.test.ts +++ b/apps/vscode/src/test/positron/notebook-export.test.ts @@ -22,41 +22,51 @@ */ import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; 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"; +const kExamplesDir = path.resolve( + __dirname, + "..", + "..", + "src", + "test", + "examples" +); +const kExamplesOutDir = path.resolve( + __dirname, + "..", + "..", + "src", + "test", + "examples-out" +); 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 + suiteSetup(async function () { + await vscode.workspace.fs.delete(vscode.Uri.file(kExamplesOutDir), { recursive: true }).then( + () => undefined, + () => undefined ); - assert.ok( - exportExt, - `Expected the built-in ${kNotebookExportExtensionId} extension to be present in Positron` + await vscode.workspace.fs.copy( + vscode.Uri.file(kExamplesDir), + vscode.Uri.file(kExamplesOutDir) ); + }); - const exportApi = await exportExt.activate(); + teardown(async function () { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + }); - // Activating Quarto is what registers the exporter against the notebook - // export API. - const quarto = extension(); - if (!quarto.isActive) { - await quarto.activate(); - } + test("registers the Quarto Markdown exporter with positron.notebook-export", async function () { + this.timeout(30000); - // 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 - ); + const quartoExporter = await waitForQuartoExporter(); assert.ok( quartoExporter, @@ -68,8 +78,58 @@ suite("Positron: notebook export", function () { "Quarto exporter should target the .qmd file extension" ); }); + + test("exports a .ipynb notebook to .qmd through notebook.export", async function () { + this.timeout(30000); + + const sourceFile = vscode.Uri.file( + path.join(kExamplesOutDir, "convert-ipynb-to-qmd.ipynb") + ); + const convertedFile = vscode.Uri.file( + path.join(kExamplesOutDir, "convert-ipynb-to-qmd.qmd") + ); + fs.rmSync(convertedFile.fsPath, { force: true, recursive: true }); + + const quartoExporter = await waitForQuartoExporter(); + + const notebook = await vscode.workspace.openNotebookDocument(sourceFile); + await vscode.window.showNotebookDocument(notebook); + + await quartoExporter!.export(notebook); + + assert.ok(fs.existsSync(convertedFile.fsPath), ".qmd file should be created"); + assert.strictEqual( + vscode.window.activeTextEditor?.document.uri.toString(), + convertedFile.toString(), + "converted .qmd did not open in the text editor" + ); + }); }); +async function waitForQuartoExporter() { + 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(); + 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. + return await waitFor( + () => exportApi.exporters.find((e) => e.label === notebookExporterLabel), + 15000 + ); +} + /** Poll `fn` until it returns a truthy value or `timeoutMs` elapses. */ async function waitFor( fn: () => T | undefined, diff --git a/claude.md b/claude.md index 26b56e319..dfef1d610 100644 --- a/claude.md +++ b/claude.md @@ -54,6 +54,7 @@ Testing procedures vary by component: - Test log output is suppressed automatically when run by Claude Code; set `VERBOSE=1` when debugging failures - The output is small enough to read directly — don't pipe through `tail` or `grep` - Read the [test configuration file](./apps/vscode/.vscode-test.mjs) for valid labels +- Positron integration tests: Run `yarn test-positron` to run the tests in `apps/vscode/src/test/positron/` inside a downloaded Positron build (rather than vanilla VS Code), covering Positron API code paths. Set `POSITRON_CHANNEL=daily` for a daily build (default: `stable`) - Other components have specific test commands defined in their respective package.json files From d33c0d2a7f06f3e886632ae4a0b331381b9d765b Mon Sep 17 00:00:00 2001 From: isabel zimmerman Date: Wed, 22 Jul 2026 17:55:00 -0400 Subject: [PATCH 8/8] lets make tests a bit leaner --- .../src/test/positron/execute-cell.test.ts | 157 +----------------- .../src/test/positron/notebook-export.test.ts | 104 +++--------- 2 files changed, 30 insertions(+), 231 deletions(-) diff --git a/apps/vscode/src/test/positron/execute-cell.test.ts b/apps/vscode/src/test/positron/execute-cell.test.ts index 6420c9c5e..16e6d3db4 100644 --- a/apps/vscode/src/test/positron/execute-cell.test.ts +++ b/apps/vscode/src/test/positron/execute-cell.test.ts @@ -33,22 +33,9 @@ */ import * as assert from "assert"; -import * as path from "path"; import * as vscode from "vscode"; import { extension } from "../extension"; import { tryAcquirePositronApi } from "@posit-dev/positron"; -import { kInlineOutputEnabledSetting } from "../../host/positron"; - -const kDeprecatedInlineOutputEnabledSetting = - "positron.quarto.inlineOutput.enabled"; -const kExamplesOutDir = path.resolve( - __dirname, - "..", - "..", - "src", - "test", - "examples-out" -); interface RuntimeCall { method: "executeCode" | "executeInlineCell"; @@ -62,31 +49,12 @@ suite("Positron: cell execution", function () { // a spy during each test and must restore it afterwards. const globalWithApi = globalThis as { acquirePositronApi?: () => unknown }; let originalAcquire: (() => unknown) | undefined; - let originalGetConfiguration: typeof vscode.workspace.getConfiguration | undefined; - const tempFiles: vscode.Uri[] = []; - teardown(async function () { + teardown(function () { if (originalAcquire !== undefined) { globalWithApi.acquirePositronApi = originalAcquire; originalAcquire = undefined; } - - if (originalGetConfiguration !== undefined) { - (vscode.workspace as typeof vscode.workspace & { - getConfiguration: typeof vscode.workspace.getConfiguration; - }).getConfiguration = originalGetConfiguration; - originalGetConfiguration = undefined; - } - - while (tempFiles.length > 0) { - const tempFile = tempFiles.pop()!; - await vscode.workspace.fs.delete(tempFile).then( - () => undefined, - () => undefined - ); - } - - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); }); /** @@ -96,8 +64,7 @@ suite("Positron: cell execution", function () { */ async function runCellAndCaptureCalls( qmd: string, - codeLine: number, - uri?: vscode.Uri + codeLine: number ): Promise { const positron = tryAcquirePositronApi(); assert.ok( @@ -143,12 +110,10 @@ suite("Positron: cell execution", function () { }); globalWithApi.acquirePositronApi = () => fakeApi; - const doc = uri - ? await openSavedQuartoDocument(uri, qmd) - : await vscode.workspace.openTextDocument({ - language: "quarto", - content: qmd, - }); + 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); @@ -156,53 +121,6 @@ suite("Positron: cell execution", function () { return calls; } - async function openSavedQuartoDocument( - uri: vscode.Uri, - qmd: string - ): Promise { - await vscode.workspace.fs.createDirectory(vscode.Uri.file(kExamplesOutDir)); - await vscode.workspace.fs.writeFile(uri, Buffer.from(qmd, "utf8")); - tempFiles.push(uri); - return await vscode.workspace.openTextDocument(uri); - } - - function makeTempQuartoUri(prefix: string): vscode.Uri { - return vscode.Uri.file( - path.join(kExamplesOutDir, `${prefix}-${Date.now()}.qmd`) - ); - } - - function mockInlineOutputEnabled(enabled: boolean): void { - originalGetConfiguration ??= vscode.workspace.getConfiguration; - (vscode.workspace as typeof vscode.workspace & { - getConfiguration: typeof vscode.workspace.getConfiguration; - }).getConfiguration = ((...args: Parameters) => { - const config = originalGetConfiguration!(...args); - return { - has: config.has.bind(config), - update: config.update.bind(config), - inspect: ((section: string) => { - if (section === kInlineOutputEnabledSetting) { - return { globalValue: enabled }; - } - if (section === kDeprecatedInlineOutputEnabledSetting) { - return { globalValue: undefined }; - } - return config.inspect(section); - }) as typeof config.inspect, - get: ((section: string, defaultValue?: unknown) => { - if ( - section === kInlineOutputEnabledSetting || - section === kDeprecatedInlineOutputEnabledSetting - ) { - return enabled; - } - return config.get(section, defaultValue); - }) as typeof config.get, - } as typeof config; - }) as typeof vscode.workspace.getConfiguration; - } - test("submits a Python cell to executeCode as python, with cell options stripped", async function () { const marker = `qmd_marker_${Date.now()}`; const qmd = [ @@ -217,11 +135,7 @@ suite("Positron: cell execution", function () { "", ].join("\n"); // Cursor on the statement (line 6), i.e. below the `#|` option line. - const calls = await runCellAndCaptureCalls( - qmd, - 6, - makeTempQuartoUri("positron-execute-code") - ); + const calls = await runCellAndCaptureCalls(qmd, 6); const call = calls.find( (c) => c.method === "executeCode" && c.args[0] === "python" @@ -256,11 +170,7 @@ suite("Positron: cell execution", function () { "", ].join("\n"); // Cursor on the statement (line 5). - const calls = await runCellAndCaptureCalls( - qmd, - 5, - makeTempQuartoUri("positron-knitr-python") - ); + 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. @@ -283,57 +193,6 @@ suite("Positron: cell execution", function () { "the original Python code should be embedded in the reticulate call" ); }); - - test("uses executeInlineCell for a saved qmd when inline output is enabled", async function () { - const marker = `qmd_marker_${Date.now()}`; - const qmd = [ - "---", - "title: test", - "---", - "", - "```{python}", - "#| echo: false", - `${marker} = 42`, - "```", - "", - ].join("\n"); - - mockInlineOutputEnabled(true); - - const uri = makeTempQuartoUri("positron-inline"); - const calls = await runCellAndCaptureCalls(qmd, 6, uri); - - assert.ok( - !calls.some((c) => c.method === "executeCode"), - "Saved documents with inline output enabled should not fall back to executeCode" - ); - - const call = calls.find((c) => c.method === "executeInlineCell"); - assert.ok( - call, - "Running a saved document with inline output enabled should call positron.runtime.executeInlineCell(...). " + - `Observed: ${describe(calls)}` - ); - - assert.ok( - vscode.Uri.isUri(call!.args[0]) && - call!.args[0].toString() === uri.toString(), - "executeInlineCell should receive the saved document URI" - ); - - const ranges = call!.args[1] as vscode.Range[]; - assert.ok( - Array.isArray(ranges) && ranges.length === 1, - "executeInlineCell should receive the current cell range" - ); - - const metadata = call!.args[2] as Record[] | undefined; - assert.strictEqual( - metadata?.[0]?.echo, - false, - "executeInlineCell should receive parsed Quarto cell metadata" - ); - }); }); /** Compact summary of observed runtime calls for assertion messages. */ diff --git a/apps/vscode/src/test/positron/notebook-export.test.ts b/apps/vscode/src/test/positron/notebook-export.test.ts index 2637c76e6..1afb34f8c 100644 --- a/apps/vscode/src/test/positron/notebook-export.test.ts +++ b/apps/vscode/src/test/positron/notebook-export.test.ts @@ -22,51 +22,41 @@ */ import * as assert from "assert"; -import * as fs from "fs"; -import * as path from "path"; 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"; -const kExamplesDir = path.resolve( - __dirname, - "..", - "..", - "src", - "test", - "examples" -); -const kExamplesOutDir = path.resolve( - __dirname, - "..", - "..", - "src", - "test", - "examples-out" -); suite("Positron: notebook export", function () { - suiteSetup(async function () { - await vscode.workspace.fs.delete(vscode.Uri.file(kExamplesOutDir), { recursive: true }).then( - () => undefined, - () => undefined + test("registers the Quarto Markdown exporter with positron.notebook-export", async function () { + this.timeout(30000); + + const exportExt = vscode.extensions.getExtension( + kNotebookExportExtensionId ); - await vscode.workspace.fs.copy( - vscode.Uri.file(kExamplesDir), - vscode.Uri.file(kExamplesOutDir) + assert.ok( + exportExt, + `Expected the built-in ${kNotebookExportExtensionId} extension to be present in Positron` ); - }); - teardown(async function () { - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - }); + const exportApi = await exportExt.activate(); - test("registers the Quarto Markdown exporter with positron.notebook-export", async function () { - this.timeout(30000); + // Activating Quarto is what registers the exporter against the notebook + // export API. + const quarto = extension(); + if (!quarto.isActive) { + await quarto.activate(); + } - const quartoExporter = await waitForQuartoExporter(); + // 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, @@ -78,58 +68,8 @@ suite("Positron: notebook export", function () { "Quarto exporter should target the .qmd file extension" ); }); - - test("exports a .ipynb notebook to .qmd through notebook.export", async function () { - this.timeout(30000); - - const sourceFile = vscode.Uri.file( - path.join(kExamplesOutDir, "convert-ipynb-to-qmd.ipynb") - ); - const convertedFile = vscode.Uri.file( - path.join(kExamplesOutDir, "convert-ipynb-to-qmd.qmd") - ); - fs.rmSync(convertedFile.fsPath, { force: true, recursive: true }); - - const quartoExporter = await waitForQuartoExporter(); - - const notebook = await vscode.workspace.openNotebookDocument(sourceFile); - await vscode.window.showNotebookDocument(notebook); - - await quartoExporter!.export(notebook); - - assert.ok(fs.existsSync(convertedFile.fsPath), ".qmd file should be created"); - assert.strictEqual( - vscode.window.activeTextEditor?.document.uri.toString(), - convertedFile.toString(), - "converted .qmd did not open in the text editor" - ); - }); }); -async function waitForQuartoExporter() { - 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(); - 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. - return await waitFor( - () => exportApi.exporters.find((e) => e.label === notebookExporterLabel), - 15000 - ); -} - /** Poll `fn` until it returns a truthy value or `timeoutMs` elapses. */ async function waitFor( fn: () => T | undefined,