Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test-positron.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ public/dist
.ipynb_checkpoints
/.luarc.json
.vscode-test
.positron-test
*.vsix
*.timestamp-*.mjs
2 changes: 1 addition & 1 deletion apps/vscode/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions apps/vscode/scripts/run-positron-tests.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
203 changes: 203 additions & 0 deletions apps/vscode/src/test/positron/execute-cell.test.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeCall[]> {
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<string, unknown> };
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] }))
);
}
54 changes: 54 additions & 0 deletions apps/vscode/src/test/positron/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
});
}
Loading
Loading