Skip to content
Merged
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
48 changes: 48 additions & 0 deletions .github/workflows/positron-api-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Positron API Tests

on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:

permissions:
contents: read

env:
POSITRON_CHANNEL: stable

jobs:
test:
# @posit-dev/positron-test-electron currently supports macOS only.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- id: get-date
run: echo "date=$(/bin/date -u "+%Y%m%d")" >> $GITHUB_OUTPUT
shell: bash
# Cache the Positron download, keyed by day so new stable builds are
# picked up within 24 hours.
- uses: actions/cache/restore@v4
id: cache
with:
path: .positron-test
key: positron-${{ env.POSITRON_CHANNEL }}-${{ steps.get-date.outputs.date }}
- run: npm ci
- name: Run Positron API tests
uses: posit-dev/setup-positron@main
with:
positron-channel: ${{ env.POSITRON_CHANNEL }}
run: npm run test-positron
- uses: actions/cache/save@v4
if: steps.cache.outputs.cache-hit != 'true'
with:
path: .positron-test
key: positron-${{ env.POSITRON_CHANNEL }}-${{ steps.get-date.outputs.date }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ dist
node_modules
__pycache__/
.vscode-test/
.positron-test/
*.vsix
*.tsbuildinfo
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,12 @@
"check-types": "tsc --noEmit",
"lint": "eslint src",
"test": "node ./out/test/runTest.js",
"pretest-positron": "npm run compile",
"test-positron": "node ./scripts/run-positron-tests.mjs",
"vsix": "npx --yes @vscode/vsce package"
},
"devDependencies": {
"@posit-dev/positron-test-electron": "^0.0.2",
"@types/glob": "^8.1.0",
"@types/mocha": "^10.0.6",
"@types/node": "20.11.22",
Expand Down
50 changes: 50 additions & 0 deletions scripts/run-positron-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Launcher for the Positron-only integration tests (src/test/positron/).
//
// Downloads (or reuses a cached) Positron build and runs the compiled Mocha
// entry point (out/test/positron/index.js) inside it, via
// @posit-dev/positron-test-electron.
//
// Run with `npm run test-positron` (which builds the extension and tests
// first). Set POSITRON_CHANNEL=daily to test against a daily Positron build
// (default: stable).
//
// NOTE: @posit-dev/positron-test-electron currently supports macOS only;
// Windows/Linux support is planned upstream.

import { runTests } from "@posit-dev/positron-test-electron";
import * as path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function main() {
// Extension root (contains package.json); scripts/ lives one level below it.
const extensionDevelopmentPath = path.resolve(__dirname, "..");

// Compiled Mocha entry point that discovers and runs the Positron tests.
const extensionTestsPath = path.resolve(
extensionDevelopmentPath,
"out",
"test",
"positron",
"index.js"
);

const code = await runTests({
channel: process.env.POSITRON_CHANNEL === "daily" ? "daily" : "stable",
extensionDevelopmentPath,
extensionTestsPath,
// The run-app and viewer tests exercise Positron's bundled
// positron-run-app extension, 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);
});
9 changes: 4 additions & 5 deletions src/extension-api-utils/extensionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function getExtensionHostPreview():
* Get the PreviewSourceType.Terminal enum value from Positron's API.
* Returns undefined if not running in Positron or if the enum is not available.
*/
export function getPreviewSourceTypeTerminal(): number | undefined {
export function getPreviewSourceTypeTerminal(): PreviewSourceType | undefined {
const pst = getPositronAPI();
if (!pst) {
return undefined;
Expand Down Expand Up @@ -103,10 +103,9 @@ export function getIdeName() {
export async function getPositronRunAppApi(): Promise<
PositronRunApp | undefined
> {
const ext =
vscode.extensions.getExtension<PositronRunApp>(
"positron.positron-run-app"
);
const ext = vscode.extensions.getExtension<PositronRunApp>(
"positron.positron-run-app"
);
if (!ext) {
return undefined;
}
Expand Down
15 changes: 11 additions & 4 deletions src/net-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getExtensionHostPreview,
getPreviewSourceTypeTerminal,
type PreviewSource,
type PreviewSourceType,
} from "./extension-api-utils/extensionHost";
import { getRemoteSafeUrl } from "./extension-api-utils/getRemoteSafeUrl";
import type { PreviewMode } from "./positron-run-app";
Expand Down Expand Up @@ -118,7 +119,9 @@ async function getTerminalClosedPromise(
* @returns Open browser timeout in milliseconds.
*/
export function configShinyTimeoutOpenBrowser(): number {
const seconds = vscode.workspace.getConfiguration().get<number>("shiny.timeoutOpenBrowser", 10);
const seconds = vscode.workspace
.getConfiguration()
.get<number>("shiny.timeoutOpenBrowser", 10);
return Math.max(1, seconds) * 1000;
}

Expand Down Expand Up @@ -233,7 +236,9 @@ function configShinyPreviewTypeForTerminal(): string {
* passed through unresolved: positron-run-app understands that value itself,
* resolving it via its own `positron.runApp.previewMode` setting.
*/
export function configShinyPreviewTypeForPositronConsole(): PreviewMode | "default" {
export function configShinyPreviewTypeForPositronConsole():
| PreviewMode
| "default" {
const previewType =
vscode.workspace.getConfiguration().get<string>("shiny.previewType") ||
"default";
Expand Down Expand Up @@ -269,8 +274,10 @@ async function buildPreviewSource(
return undefined;
}

// Try to get the enum value from Positron's API, fall back to hardcoded value
const terminalType = getPreviewSourceTypeTerminal() ?? 2;
// Try to get the enum value from Positron's API, fall back to the
// hardcoded wire value ("terminal" on every Positron version we support).
const terminalType =
getPreviewSourceTypeTerminal() ?? ("terminal" as PreviewSourceType);

return { type: terminalType, id: processId };
}
Expand Down
64 changes: 64 additions & 0 deletions src/test/positron/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Positron API Tests

Integration tests that run the Shiny extension inside a real
[Positron](https://positron.posit.co/) build and exercise its use of the
Positron API — code paths that the plain VS Code suite (`src/test/suite/`)
can't reach because they depend on Positron's extension host (the
`acquirePositronApi` global, the bundled `positron-run-app` extension, and
the Viewer pane).

Part of the rollout tracked in
[posit-dev/positron#14531](https://github.com/posit-dev/positron/issues/14531)
(pattern established in
[quarto-dev/quarto#1058](https://github.com/quarto-dev/quarto/pull/1058) and
[posit-dev/publisher#4298](https://github.com/posit-dev/publisher/pull/4298)).

## How it works

- `scripts/run-positron-tests.mjs` uses
[`@posit-dev/positron-test-electron`](https://github.com/posit-dev/positron-test-electron)
to download (and cache, under `.positron-test/`) a Positron build, then
runs the compiled Mocha entry point (`out/test/positron/index.js`) inside
its extension host — the Positron analog of `@vscode/test-electron`.
- `index.ts` is that entry point: it discovers `*.test.js` files in this
directory and runs them with Mocha (tdd UI).
- Tests are compiled by `esbuild.ts` along with the plain suite (its `test`
context already globs `src/test/**/*.ts`); the plain suite's runner
(`src/test/suite/index.ts`) ignores `out/test/positron/` so `npm test`
doesn't pick these up in vanilla VS Code.
- Positron's bundled extensions are left enabled (no `--disable-extensions`)
because the run-app and Viewer tests exercise the bundled
`positron-run-app` extension.

## Running locally

```bash
npm run test-positron # against the latest stable Positron
POSITRON_CHANNEL=daily npm run test-positron # against a daily build
```

> **Note:** `@posit-dev/positron-test-electron` currently supports **macOS
> only**. On other platforms, rely on the `Positron API Tests` GitHub Actions
> workflow (`.github/workflows/positron-api-tests.yaml`), which runs on every
> PR and push to `main`.

## Adding tests

Add a `<name>.test.ts` file in this directory using Mocha's tdd UI
(`suite`/`test`). Things to know:

- The Positron API is reached through the `acquirePositronApi()` global that
Positron injects into the extension host (typed by
`src/types/positron.d.ts`). The extension's own code feature-detects
Positron the same way — see `src/extension-api-utils/extensionHost.ts`.
- Prefer testing the extension's real behavior at the API boundary: import
the extension source (e.g. `import { ... } from
"../../extension-api-utils/extensionHost"`) and assert on what it sends to
/ receives from the live API, rather than poking the API directly.
- Keep tests independent of a live kernel actually starting whenever
possible — metadata-level assertions (e.g.
`positron.runtime.getPreferredRuntime`) are much faster and less flaky
than starting an R/Python session. If you do need runtime discovery (e.g.
to test `getRPathFromPositron` in `src/run.ts`), the CI runner will also
need R/Python installed, and the test should poll until Positron reports a
runtime before asserting.
48 changes: 48 additions & 0 deletions src/test/positron/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Positron-only integration test.
//
// Sanity checks for the contract this extension depends on when running
// inside Positron: the extension host injects an `acquirePositronApi` global
// (which the extension feature-detects Positron with — see
// src/extension-api-utils/extensionHost.ts), and the Shiny extension
// activates in the Positron extension host.

import * as assert from "assert";
import * as vscode from "vscode";
import {
getIdeName,
isPositron,
} from "../../extension-api-utils/extensionHost";

suite("Positron: extension host", () => {
test("Positron injects the acquirePositronApi global", () => {
assert.strictEqual(
typeof acquirePositronApi,
"function",
"the extension host should provide the acquirePositronApi global"
);

const api = acquirePositronApi();
assert.ok(api, "acquirePositronApi() should return the Positron API");
assert.strictEqual(typeof api.version, "string");
assert.ok(
api.version.length > 0,
"the Positron API should report a version"
);
});

test("the extension's own feature detection recognizes Positron", () => {
// These are the helpers the extension uses everywhere to branch between
// Positron and VS Code behavior (Viewer pane vs. Simple Browser, console
// vs. terminal, etc.).
assert.strictEqual(isPositron(), true);
assert.strictEqual(getIdeName(), "Positron");
});

test("the Shiny extension activates in Positron", async () => {
const shiny = vscode.extensions.getExtension("posit.shiny");
assert.ok(shiny, "posit.shiny should be present in the extension host");

await shiny.activate();
assert.ok(shiny.isActive, "Shiny should activate without error");
});
});
47 changes: 47 additions & 0 deletions src/test/positron/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Mocha entry point for the Positron-only integration tests. This module is
// loaded inside the Positron extension host by
// @posit-dev/positron-test-electron (see scripts/run-positron-tests.mjs),
// which requires it and calls run().
//
// These tests are kept separate from the plain VS Code suite
// (src/test/suite/) because they exercise the Positron API, which is only
// available when the tests run inside Positron rather than vanilla VS Code.

import * as fs from "fs";
import * as path from "path";

// `import =` so esbuild emits a plain require: mocha is a CJS constructor,
// and esbuild's ESM interop namespace for `import * as` is not constructable.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import Mocha = require("mocha");

export function run(): Promise<void> {
const mocha = new Mocha({
ui: "tdd",
color: true,
// Extension activation on a cold CI machine can be slow, so give each
// test a generous ceiling.
timeout: 120000,
});

const testsRoot = __dirname;
for (const file of fs.readdirSync(testsRoot)) {
if (file.endsWith(".test.js")) {
mocha.addFile(path.resolve(testsRoot, file));
}
}

return new Promise((resolve, reject) => {
try {
mocha.run((failures) => {
if (failures > 0) {
reject(new Error(`${failures} test(s) failed.`));
} else {
resolve();
}
});
} catch (err) {
reject(err);
}
});
}
Loading
Loading