diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7704a4b1d..6e046598a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -29,6 +29,9 @@ jobs: - name: Install tree-sitter-cli run: npm install -g tree-sitter-cli + - name: Update apt package index + run: sudo apt-get update + - name: Install LLVM run: sudo apt-get install -y llvm diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a092ed2c4..568e1193a 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -30,6 +30,9 @@ jobs: - name: Install tree-sitter-cli run: npm install -g tree-sitter-cli + - name: Update apt package index + run: sudo apt-get update + - name: Install LLVM run: sudo apt-get install -y llvm diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index a463184fe..ce5335885 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -52,6 +52,13 @@ jobs: SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} SM_CLIENT_CERT_FINGERPRINT: ${{ secrets.SM_CLIENT_CERT_FINGERPRINT }} + - name: Upload ggsql-jupyter kernel (win32-x64) + uses: actions/upload-artifact@v4 + with: + name: ggsql-jupyter-win32-x64 + path: target/release/ggsql-jupyter.exe + retention-days: 30 + - name: Build NSIS installer run: cargo packager --release --formats nsis @@ -153,6 +160,13 @@ jobs: --entitlements entitlements.plist \ --sign "$SIGN_ID" target/release/ggsql-jupyter + - name: Upload ggsql-jupyter kernel (darwin-x64) + uses: actions/upload-artifact@v4 + with: + name: ggsql-jupyter-darwin-x64 + path: target/release/ggsql-jupyter + retention-days: 30 + - name: Build and notarize PKG installer (x86_64) # NOTE: --sign uses the Developer ID *Installer* cert (signs .pkg only), # distinct from the Developer ID Application cert used to sign Mach-O above. @@ -260,6 +274,13 @@ jobs: --entitlements entitlements.plist \ --sign "$SIGN_ID" target/release/ggsql-jupyter + - name: Upload ggsql-jupyter kernel (darwin-arm64) + uses: actions/upload-artifact@v4 + with: + name: ggsql-jupyter-darwin-arm64 + path: target/release/ggsql-jupyter + retention-days: 30 + - name: Build and notarize PKG installer (aarch64) # NOTE: --sign uses the Developer ID *Installer* cert (signs .pkg only), # distinct from the Developer ID Application cert used to sign Mach-O above. @@ -337,6 +358,13 @@ jobs: - name: Build ggsql binary (x86_64) run: cargo build --release --bin ggsql --bin ggsql-jupyter + - name: Upload ggsql-jupyter kernel (linux-x64) + uses: actions/upload-artifact@v4 + with: + name: ggsql-jupyter-linux-x64 + path: target/release/ggsql-jupyter + retention-days: 30 + - name: Build Debian package (x86_64) run: cargo packager --release --formats deb @@ -384,6 +412,13 @@ jobs: - name: Build ggsql binary (aarch64) run: cargo build --release --bin ggsql --bin ggsql-jupyter + - name: Upload ggsql-jupyter kernel (linux-arm64) + uses: actions/upload-artifact@v4 + with: + name: ggsql-jupyter-linux-arm64 + path: target/release/ggsql-jupyter + retention-days: 30 + - name: Build Debian package (aarch64) run: cargo packager --release --formats deb @@ -481,6 +516,9 @@ jobs: # stable. ggsql-wasm/rust-toolchain.toml also selects stable for the build. uses: dtolnay/rust-toolchain@stable + - name: Update apt package index + run: sudo apt-get update + - name: Install LLVM run: sudo apt-get install -y llvm @@ -545,6 +583,138 @@ jobs: - name: Publish to npm run: npm publish ./npm-tarball/*.tgz --access=public --provenance --tag ${{ steps.dist-tag.outputs.tag }} + build-vsix: + name: Build VSIX (${{ matrix.target }}) + needs: [build-windows, build-macos-x86_64, build-macos-aarch64, build-linux-x86_64, build-linux-aarch64] + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + # win32-arm64 is deliberately absent: no runner builds that kernel yet. + # "universal" carries no kernel and is what users on any other platform + # install, alongside the kernel from a native installer. + target: + - darwin-arm64 + - darwin-x64 + - linux-arm64 + - linux-x64 + - win32-x64 + - universal + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ggsql-vscode/package-lock.json + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Install dependencies + working-directory: ggsql-vscode + run: npm ci + + - name: Download ggsql-jupyter kernel (${{ matrix.target }}) + if: matrix.target != 'universal' + uses: actions/download-artifact@v4 + with: + name: ggsql-jupyter-${{ matrix.target }} + path: ggsql-vscode/bundled/bin + + - name: Make the kernel executable + if: matrix.target != 'universal' + run: | + chmod +x ggsql-vscode/bundled/bin/* + ls -l ggsql-vscode/bundled/bin + + - name: Package VSIX + id: package + working-directory: ggsql-vscode + env: + TARGET: ${{ matrix.target }} + run: | + VERSION="$(node -p 'require("./package.json").version')" + VSIX="ggsql-${VERSION}-${TARGET}.vsix" + if [ "$TARGET" = universal ]; then + vsce package --out "$VSIX" + else + # --target writes TargetPlatform into the vsixmanifest, which is what + # Open VSX records and what Positron's bootstrap asks for by name. + vsce package --target "$TARGET" --out "$VSIX" + fi + echo "vsix=$VSIX" >> "$GITHUB_OUTPUT" + + - name: Check the VSIX contents + working-directory: ggsql-vscode + env: + TARGET: ${{ matrix.target }} + VSIX: ${{ steps.package.outputs.vsix }} + run: | + unzip -l "$VSIX" + unzip -p "$VSIX" extension.vsixmanifest \ + | grep -o 'TargetPlatform="[^"]*"' || echo 'no TargetPlatform: universal' + if [ "$TARGET" = universal ]; then + if unzip -l "$VSIX" | grep -q 'extension/bundled/'; then + echo "::error::the universal VSIX must not carry a kernel" + exit 1 + fi + elif ! unzip -l "$VSIX" | grep -q 'extension/bundled/bin/ggsql-jupyter'; then + echo "::error::the $TARGET VSIX is missing its bundled kernel" + exit 1 + fi + + - name: Upload VSIX + uses: actions/upload-artifact@v4 + with: + name: ggsql-vsix-${{ matrix.target }} + path: ggsql-vscode/${{ steps.package.outputs.vsix }} + retention-days: 30 + + publish-openvsx: + name: Publish VSIX (${{ matrix.target }}) + # Separate from build-vsix so that a registry failure neither blocks the + # GitHub release nor forces the VSIXes to be rebuilt on a retry. + needs: [build-vsix] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: + - darwin-arm64 + - darwin-x64 + - linux-arm64 + - linux-x64 + - win32-x64 + - universal + + steps: + - name: Download VSIX + uses: actions/download-artifact@v4 + with: + name: ggsql-vsix-${{ matrix.target }} + path: vsix + + - name: Locate the VSIX + id: vsix + run: echo "path=$(ls vsix/*.vsix)" >> "$GITHUB_OUTPUT" + + - name: Publish to Open VSX Registry + uses: HaaLeo/publish-vscode-extension@v2 + with: + pat: ${{ secrets.OPEN_VSX_TOKEN }} + skipDuplicate: true + extensionFile: ${{ steps.vsix.outputs.path }} + create-release: name: Create GitHub Release needs: [build-windows, build-macos-x86_64, build-macos-aarch64, build-linux-x86_64, build-linux-aarch64, build-cargo, build-wasm] @@ -566,10 +736,10 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/*.exe - artifacts/**/*.msi - artifacts/**/*.pkg - artifacts/**/*.deb - artifacts/**/*.tgz + artifacts/ggsql-windows-nsis/*.exe + artifacts/ggsql-windows-msi/*.msi + artifacts/ggsql-macos-pkg-*/*.pkg + artifacts/ggsql-linux-deb-*/*.deb + artifacts/ggsql-wasm-npm/*.tgz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-vscode.yaml b/.github/workflows/release-vscode.yaml deleted file mode 100644 index c14d5f0e1..000000000 --- a/.github/workflows/release-vscode.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: Open VSX Release - -on: - push: - tags: - - "v*" - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install vsce - run: npm install -g @vscode/vsce - - - name: Install dependencies - working-directory: ggsql-vscode - run: npm ci - - - name: Package VSIX - working-directory: ggsql-vscode - run: vsce package - - - name: Publish to Open VSX Registry - if: startsWith(github.ref, 'refs/tags/v') - uses: HaaLeo/publish-vscode-extension@v2 - with: - pat: ${{ secrets.OPEN_VSX_TOKEN }} - skipDuplicate: true - packagePath: ggsql-vscode diff --git a/.github/workflows/test-extension.yaml b/.github/workflows/test-extension.yaml index 2816e727e..14e2958a9 100644 --- a/.github/workflows/test-extension.yaml +++ b/.github/workflows/test-extension.yaml @@ -8,14 +8,20 @@ on: workflow_dispatch: jobs: - # Runs the suite against stock VS Code. A sibling job will run the same - # extension against Positron, which covers the language runtime, connection - # drivers and cell execution that stock VS Code cannot reach. + # Runs the suite against stock VS Code, on all three platforms the extension + # ships a bundled kernel for. test-extension: - runs-on: ubuntu-latest - name: Test (VS Code) + runs-on: ${{ matrix.os }} + name: Test (VS Code, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] defaults: run: + # bash on every runner, so the npm scripts and their globs behave + # identically rather than going through PowerShell on Windows. + shell: bash working-directory: ggsql-vscode steps: @@ -32,13 +38,115 @@ jobs: - name: Install XVFB # The extension tests drive a real VS Code instance, which needs a # display. The grammar tests do not, but run under the same command. + if: runner.os == 'Linux' run: sudo apt-get -y update && sudo apt-get -y install xvfb - name: Install dependencies run: npm ci - name: Lint + # Nothing platform-specific to check, so once is enough. + if: runner.os == 'Linux' run: npm run lint - name: Run tests + if: runner.os == 'Linux' run: xvfb-run -a npm test + + - name: Run tests + if: runner.os != 'Linux' + run: npm test + + # Packaging invariants the release workflow depends on. That workflow only + # runs on a tag, so on its own a broken .vscodeignore would not surface + # until release time. + # + # This runs *after* the tests, deliberately: by now .vscode-test/ holds a + # downloaded VS Code, which is the state that catches an ignore rule + # letting a test cache into the package. A fresh checkout cannot. + - name: Package VSIX + if: runner.os == 'Linux' + run: npx --yes @vscode/vsce package --out ggsql-universal.vsix + + - name: Check the VSIX contents + if: runner.os == 'Linux' + run: | + unzip -l ggsql-universal.vsix + # No kernel was staged, so this is the universal build. + for unwanted in 'extension/bundled/' '\.vscode-test' '\.positron-test' '\.d\.ts$'; do + if unzip -l ggsql-universal.vsix | grep -q "$unwanted"; then + echo "::error::$unwanted must not be in the VSIX" + exit 1 + fi + done + for required in extension/out/extension.js extension/syntaxes/ggsql.tmLanguage.json extension/resources/ggsql-icon.svg; do + if ! unzip -l ggsql-universal.vsix | grep -q "$required"; then + echo "::error::$required is missing from the VSIX" + exit 1 + fi + done + + # Check the bundled binary actually starts. + test-integration: + runs-on: ubuntu-latest + name: Integration (Positron + bundled kernel) + defaults: + run: + working-directory: ggsql-vscode + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ggsql-vscode/package-lock.json + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.86.0 + + # Borrow publish.yaml's cargo cache rather than keeping one of our own. + # That workflow runs on every PR too and builds this very binary, with + # the same toolchain and lockfile, so the key matches exactly and the + # dependencies come back warm — the kernel build is ~16 minutes cold and + # under 3 warm. `save-if: false` is the point: this job only ever reads, + # so it costs nothing against a 10 GB repo-wide cache limit that other + # workflows have already mostly spent. + # + # The coupling is to publish.yaml's `shared-key` and its 1.86 toolchain. + # Change either there and this silently goes cold, which is slow rather + # than broken; the same is true if that entry is ever evicted. + - name: Restore the cargo cache built by publish.yaml + uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ runner.os }}-publish + save-if: false + + - name: Install tree-sitter-cli + run: npm install -g tree-sitter-cli + + - name: Install XVFB + run: sudo apt-get -y update && sudo apt-get -y install xvfb + + - name: Build the kernel + working-directory: . + run: cargo build --release --bin ggsql-jupyter + + - name: Stage the kernel where the VSIX would carry it + working-directory: . + run: | + mkdir -p ggsql-vscode/bundled/bin + cp target/release/ggsql-jupyter ggsql-vscode/bundled/bin/ + chmod +x ggsql-vscode/bundled/bin/ggsql-jupyter + + - name: Install dependencies + run: npm ci + + # Downloads Positron, which is deliberately not cached: the daily channel + # moves most days, so a cache would hold an entry of ~1 GB per version for + # a download that takes under a minute. + - name: Run integration tests + run: xvfb-run -a npm run test:integration diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec8c0c15..dead2ba5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,16 @@ transformation. This has no Vega-Lite equivalent and is ignored by that writer; the png writer draws them. +- The VS Code / Positron extension now ships the `ggsql-jupyter` kernel, so + installing the extension is all that is needed to run queries. It is offered + alongside every ggsql kernel found on the machine — a Jupyter kernelspec, a + native install, one on `PATH`, or the path in `ggsql.kernelPath` — each named + for the version it reports, so the New Console Session picker shows which is + which. A kernel too old to report one is still offered, named without a + version. The bundled kernel is the default. + +- `ggsql-jupyter` accepts `--version`. + ### Changed - Dodging now only takes effect where groups actually meet on a position. A layer whose grouping gives every group a position of its own — `colour` mapped @@ -64,6 +74,7 @@ category ticks pulled toward the middle of the panel. ### Fixed +- Positron no longer offers a ggsql runtime on a machine that has no kernel. - A dodged violin or half-boxplot on a categorical `y` axis is no longer flipped in the Vega-Lite writer. Both took their band displacement from an encoding of their own that read a ggsql offset as pointing down the screen, so their groups diff --git a/ggsql-jupyter/CLAUDE.md b/ggsql-jupyter/CLAUDE.md index adcb5ecd3..9904a044f 100644 --- a/ggsql-jupyter/CLAUDE.md +++ b/ggsql-jupyter/CLAUDE.md @@ -39,7 +39,7 @@ ggsql-jupyter/ - Kernel info advertises `"output_location": "plot"` so visualizations route to Positron's Plot pane. - `data_explorer.rs` implements Positron's data-explorer comm channel (registered query results become explorable tables). -- The companion VS Code extension (`ggsql-vscode/`) discovers this binary via the `ggsql.kernelPath` setting, the active Jupyter kernelspec, or `PATH`. +- The companion VS Code extension (`ggsql-vscode/`) ships a copy of this binary and also discovers installed ones via the `ggsql.kernelPath` setting, the Jupyter kernelspec directories, the native install locations, or `PATH`. It runs each one with `--version` to name the runtime it registers, so **keep `--version` working**: a kernel that does not answer it is still offered, but without a version in the picker. See [Finding the kernel](../ggsql-vscode/CLAUDE.md#finding-the-kernel). ## Build & install diff --git a/ggsql-jupyter/src/main.rs b/ggsql-jupyter/src/main.rs index fccb8906b..fc2c44cea 100644 --- a/ggsql-jupyter/src/main.rs +++ b/ggsql-jupyter/src/main.rs @@ -18,6 +18,7 @@ use std::process::Command; #[derive(Parser)] #[command(name = "ggsql-jupyter")] #[command(about = "Jupyter kernel for ggsql", long_about = None)] +#[command(version)] struct Args { /// Path to the Jupyter connection file #[arg(short = 'f', long = "connection-file")] diff --git a/ggsql-vscode/.gitignore b/ggsql-vscode/.gitignore index 7eaa3d359..d8d1593a6 100644 --- a/ggsql-vscode/.gitignore +++ b/ggsql-vscode/.gitignore @@ -1,3 +1,6 @@ out out-test .vscode-test/ +.positron-test/ +bundled +*.vsix diff --git a/ggsql-vscode/.vscode-test.mjs b/ggsql-vscode/.vscode-test.mjs index a6053b90a..931dc9a0c 100644 --- a/ggsql-vscode/.vscode-test.mjs +++ b/ggsql-vscode/.vscode-test.mjs @@ -1,6 +1,9 @@ import { defineConfig } from '@vscode/test-cli'; export default defineConfig({ - files: 'out-test/test/**/*.test.js', + // Only the suites directly under out-test/test/. test/integration/ is + // deliberately excluded: it needs a real Positron, which src/test/ + // runIntegration.ts downloads and launches instead (npm run test:integration). + files: 'out-test/test/*.test.js', mocha: { timeout: 5000 }, }); diff --git a/ggsql-vscode/.vscodeignore b/ggsql-vscode/.vscodeignore index 8dcb9cb3c..9e2fbb6ff 100644 --- a/ggsql-vscode/.vscodeignore +++ b/ggsql-vscode/.vscodeignore @@ -1,5 +1,6 @@ .vscode/** .vscode-test/** +.positron-test/** .gitignore .yarnrc vsc-extension-quickstart.md @@ -8,7 +9,6 @@ tsconfig.test.json **/.eslintrc.json **/*.map **/*.ts -!**/*.d.ts node_modules/** .editorconfig src/** diff --git a/ggsql-vscode/CHANGELOG.md b/ggsql-vscode/CHANGELOG.md index bc8208d04..44bf314fd 100644 --- a/ggsql-vscode/CHANGELOG.md +++ b/ggsql-vscode/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +- The extension now ships the `ggsql-jupyter` kernel, so installing it is enough + to run queries in Positron. It is the default, and is offered alongside every + ggsql kernel found on this machine — a Jupyter kernelspec, a native install, + one on `PATH`, or the path in `ggsql.kernelPath`. +- Each runtime is named for the version its kernel reports, as in `ggsql 0.4.1`, + with the kernels that are not the bundled one qualified by where they came + from: `ggsql 0.4.1 (System)`. +- Fixed: no ggsql runtime is offered when no kernel can be found, rather than one + that fails at session start with `KS-19: Kernel path not found`. +- The bundled kernel is run before it is offered, and the kernels installed on + the machine remain available when it does not start. If nothing on the machine + can run, the extension points at the install instructions once rather than + offering a runtime that fails at session start. + ## 0.3.2 - Improved configuration options shown in Positron Connection pane for sqlite diff --git a/ggsql-vscode/CLAUDE.md b/ggsql-vscode/CLAUDE.md index 4f83fe190..4917b426a 100644 --- a/ggsql-vscode/CLAUDE.md +++ b/ggsql-vscode/CLAUDE.md @@ -28,6 +28,7 @@ ggsql-vscode/ │ └── test/ Mocha suites (unit + activation) and the grammar fixture ├── syntaxes/ │ └── ggsql.tmLanguage.json TextMate grammar (used for tokenization in VS Code) +├── bundled/bin/ Kernel shipped inside the platform VSIXes (staged at release time, not in git) ├── examples/ Sample .ggsql files ├── resources/ Static assets bundled with the extension │ ├── ggsql-icon.svg Full-colour logo; read by manager.ts for base64EncodedIconSvg @@ -87,8 +88,8 @@ The `ggsql.enableSqlFiles` description uses `markdownDescription` rather than `d The extension declares `contributes.languageRuntimes` for `ggsql` (see `package.json`) and depends on `@posit-dev/positron`. When activated under Positron, `manager.ts`: -1. Discovers a `ggsql-jupyter` binary via, in order: the `ggsql.kernelPath` setting, an installed Jupyter kernelspec named `ggsql`, or `ggsql-jupyter` on `PATH`. -2. Registers it as a Positron language runtime so `▶ Run` and the Console route to the kernel. +1. Discovers `ggsql-jupyter` binaries as described in [Finding the kernel](#finding-the-kernel) below. +2. Registers each as a Positron language runtime so `▶ Run` and the Console route to the kernel. 3. Routes plot output to Positron's Plot pane via metadata coming back from the kernel (`output_location: "plot"`). Outside Positron there is no way to execute a query: `activate()` returns early, so every command that runs code stays unregistered. To avoid offering actions that cannot work, everything execution-related gates on Positron's built-in **`isPositron`** context key ([extension development docs](https://positron.posit.co/extension-development.html#option-1-context-keys)): @@ -107,11 +108,41 @@ The Positron Supervisor is a soft dependency, reached through `getSupervisorApi( Anything that does *not* need the runtime (`ggsql.createNewFile`, `ggsql.resetSqlAssociationPrompt`, syntax highlighting) is registered before the early return and works in plain VS Code. Add new commands on the correct side of that line, and gate them if they execute code. +## Finding the kernel + +The extension ships the kernel: the per-platform VSIXes carry `ggsql-jupyter` at `bundled/bin/`, so installing the extension is enough and no native installer is needed. The platform-neutral VSIX carries none, and users on a platform without a build install the kernel themselves. + +**Every kernel found is offered**, and the user picks in the New Console Session picker. There is one order, not a set of strategies: + +| Order | Source | Where | +| --- | --- | --- | +| 1 | `Setting` | `ggsql.kernelPath`, when set | +| 2 | `Bundled` | `bundled/bin/` inside the extension | +| 3 | `Jupyter` | Jupyter kernelspec directories, user then system | +| 4 | `System` | the native package install location for the platform | +| 5 | `Path` | `PATH` | + +Order decides which the picker lists first — hence which is the default — and, when two paths name one file, which occurrence survives `dedupeCandidates()`. The bundled kernel leads unless the user named one, so a machine with nothing installed gets a working runtime and a machine with an install keeps being offered it. + +`selectKernelCandidates()` is the whole rule with no filesystem in it, which is what `src/test/kernelDiscovery.test.ts` exercises; `discoverKernelPaths()` supplies it with what is actually on disk. + +Five things here are load bearing: + +- **A kernel is run before it is offered, and what it says is what the picker shows.** `probeKernel()` runs `ggsql-jupyter --version` and reads the version out of its output, so the runtime is named `ggsql 0.4.1` the way Positron's own runtimes are. Filesystem checks cannot tell whether a binary starts: the bundled kernel is built for the platform but not for every system it can be installed on, and one linked against newer shared libraries than the host provides is exec'd successfully and then killed by the dynamic linker, which no `stat` or `access` call can see. A kernel released before `--version` existed rejects the argument and exits non-zero, which by exit status alone is the same answer the loader-killed binary gives. The probe settles the two apart by asking a question every version answers — `--help` — and only a kernel that fails *that* is treated as unable to run; one that passes it is offered as plain `ggsql ()` with no version, since dropping it would take away the install the user already had. **Beyond that, only the bundled kernel has to pass at all:** a kernel the user installed is their own business and is offered even when it would not start. +- **Successful probes are cached, failures are not.** `ProbeCache` keeps them in `globalState` keyed by kernel path, with the file's mtime and size in the entry, so an install upgraded in place is probed again instead of reporting the version it used to have, and a pass costs one spawn per kernel per install rather than one per window. It rewrites the map with only the kernels the pass saw, or the paths of every superseded extension version would accumulate. A failure is not stored: it is cheap to repeat, and a host that gains the missing shared libraries should start working without waiting for an update. +- **Every candidate is an absolute path.** A candidate that is only a binary name satisfies each existence check further down and so registers a runtime that fails at session start with `KS-19: Kernel path not found`. `findOnPath()` returns `undefined` rather than the bare name, and `isKernelAccessible()` rejects any non-absolute path, so no kernel anywhere means **zero** runtimes rather than an unusable one. The single exception is a `ggsql.kernelPath` that resolves to nothing: it is passed through so discovery can report it as inaccessible in the log instead of ignoring the setting silently. +- **The bundled kernel's `runtimeId` is fixed, not derived from its path.** Every other source hashes `kernelPath` to get one id per installed kernel, but the bundled path contains the versioned extension directory, so hashing it would mint a new runtime on every extension update and lose the workspace's runtime affinity and its restorable sessions. That only holds up because `validateMetadata()` regenerates the metadata Positron stored for the workspace: the id survives the update, the path in the stored copy does not. It rejects metadata with no matching candidate, which is how Positron learns to drop a runtime whose kernel has been uninstalled. +- **The bundled runtime carries no `()` qualifier.** It is the default, so there is nothing to distinguish it from. `runtimeShortName` stays plain `ggsql` for every kernel: it labels a console tab, where the version adds nothing. + +Discovery also writes the user-level Jupyter kernelspec for the *leading* runnable kernel, so Quarto and Jupyter can find ggsql without a session ever being started, and so the spec stops pointing into an extension directory an update has removed. Only one is written, and never for a kernel that was itself found as a kernelspec — that one is already where Jupyter looks. Only a kernel that has passed the probe is written there: the spec outlives the window and is what Quarto resolves, and it has no fallback of its own. + +The one case that interrupts the user is the dead end: nothing runnable anywhere, whether because the bundled kernel failed its probe or because the build carries none (the platform-neutral VSIX). `reportNoUsableKernel()` then shows a non-modal warning once per extension version, offering the install docs and the log. A kernel that is merely skipped — an unusable `ggsql.kernelPath`, say, with others still available — is reported in the log only. + ## Settings ```json { - "ggsql.kernelPath": "string" // empty → use 'ggsql-jupyter' from PATH + "ggsql.kernelPath": "string" // an extra kernel to offer, listed first } ``` @@ -126,6 +157,17 @@ npx vsce package # produces ggsql-.vsix code --install-extension ggsql-.vsix ``` +A local `vsce package` produces the kernel-less VSIX, since `bundled/` only exists in a release build. + +**Release builds** live in [`/.github/workflows/release-packages.yml`](../.github/workflows/release-packages.yml), not in a workflow of their own. Its `build-vsix` job runs a matrix of six — the five platform targets plus `universal` — downloading the `ggsql-jupyter-` artifact each platform job uploaded between signing and installer packaging, restoring the executable bit, and running `vsce package --target `. `publish-openvsx` then publishes the packaged file to Open VSX. + +Four things about that arrangement are deliberate: + +- **The VSIX build cannot live in its own workflow.** Actions artifacts are scoped to a single workflow run, and two workflows triggered by the same tag run in parallel, so a separate workflow could not download the kernels. Building in the same run also means the kernel and the extension always come from one commit. +- **The executable bit has to be restored after download.** Artifact upload and download drop it. It does survive `vsce package` into the VSIX itself, so restoring it once in CI is enough; `ensureExecutable()` in `manager.ts` is belt-and-braces for an install that loses it. +- **The published artefact is the packaged `.vsix`, with no `target` passed to the publish action.** Open VSX reads the platform from the `TargetPlatform` attribute that `vsce package --target` writes into `extension.vsixmanifest`, and defaults to `universal` when it is absent; `ovsx` discards a target option when handed an already-packaged vsix. +- **`win32-arm64` is not built.** No runner produces that kernel yet. Positron's bootstrap appends `?targetPlatform=` and gets an HTTP 403 rather than the universal build for a target that was never published, so the universal VSIX is not a fallback for it — see posit-dev/positron#14954. + Watch mode for development: `npm run watch` (runs esbuild + tsc in parallel). For an interactive session, open the **repo root** in Positron and press F5 ("Run Extension"). [`/.vscode/launch.json`](../.vscode/launch.json) runs the `build-ggsql-vscode` task, which is `npm run watch` in this folder, then opens an Extension Development Host with `--extensionDevelopmentPath`, so the extension loads from source with no VSIX. Launch from Positron rather than VS Code, or the dev host has no Positron API and the runtime manager never registers. The watcher rebuilds `out/extension.js` on save, but the host does not hot-reload: run _Developer: Reload Window_ in the Extension Development Host to pick up a change. @@ -134,18 +176,57 @@ For an interactive session, open the **repo root** in Positron and press F5 ```sh cd ggsql-vscode -npm test # grammar scopes, then the VS Code suites -npm run test:grammar # TextMate scopes only; no Electron, fast +npm test # grammar scopes, then the VS Code suites +npm run test:grammar # TextMate scopes only; no Electron, fast npm run test:extension +npm run test:integration # downloads Positron; needs a staged kernel (see below) ``` Tests live in `src/test/` and compile to `out-test/` via `tsconfig.test.json`, deliberately not to `out/`, which `esbuild.js` owns. The whole of `src/` compiles there, not just `src/test/`, because the unit tests import the extension's own modules. `@vscode/test-cli` launches a real VS Code instance, so a window appears while the suites run; CI wraps the same command in `xvfb-run`. Note that `tsc` does not prune output for deleted sources: if you delete or rename a test, remove its `.js` and `.js.map` from `out-test/test/` or the runner keeps executing the stale copy. `npm run test:extension` on its own does not recompile, so run `npm test` (or `npm run compile-tests` first) after editing any `.ts`. -The suites cover the extension as stock VS Code sees it: activation, language resolution, cell parsing, `.sql` gating, CodeLens placement, TextMate scopes, and the parts of `manager.ts` and `positronApi.ts` that are reachable without a Positron host. `bundle.test.ts` additionally asserts against the built `out/extension.js`. The rest of the Positron surface (session creation, connection drivers, cell execution) is not covered, since it needs a Positron host, and `sqlAssociation.ts` and `connections.ts` are untested. +The suites cover the extension as stock VS Code sees it: activation, language resolution, cell parsing, `.sql` gating, CodeLens placement, TextMate scopes, kernel discovery, and the parts of `manager.ts` and `positronApi.ts` that are reachable without a Positron host. `bundle.test.ts` additionally asserts against the built `out/extension.js`. The rest of the Positron surface (session creation, connection drivers, cell execution) is not covered, since it needs a Positron host, and `sqlAssociation.ts` and `connections.ts` are untested. + +Add new tests as `src/test/.test.ts`; no config change is needed. `.vscode-test.mjs` globs `out-test/test/*.test.js` — one level only, deliberately, so the Positron suite in `test/integration/` does not run under stock VS Code, where it cannot pass. + +### The Positron integration suite + +`src/test/integration/` is the only place a kernel is actually launched. The unit suites cover discovery precedence and metadata, and `build-vsix` proves the binary is inside the VSIX; neither can tell whether it *starts*, which is the failure the bundling work exists to fix. `npm run test:integration` builds nothing itself — stage a kernel first: + +```sh +cargo build --release --bin ggsql-jupyter +mkdir -p ggsql-vscode/bundled/bin && cp target/release/ggsql-jupyter ggsql-vscode/bundled/bin/ +``` + +`src/test/runIntegration.ts` then downloads Positron via [`@posit-dev/positron-test-electron`](https://github.com/posit-dev/positron-test-electron) and runs the suite in its extension host. Three details are load bearing: + +- **`disableExtensions: false`.** Session creation goes through `positron.positron-supervisor`, one of Positron's bundled extensions. Under the harness's default `--disable-extensions` there is no supervisor and every session start fails. +- **The suite drives mocha itself.** `extensionTestsPath` must resolve to a module exporting `run()`, which is why `test/integration/index.ts` exists instead of the `@vscode/test-cli` config the other suites use. Its timeout is 120s: a session start spawns the binary and completes a Jupyter handshake. +- **`channel: 'daily'`.** Positron's stable channel is not published for every platform. Pin `version` instead once a known-good build is worth freezing. + +The download is cached in `.positron-test/`, gitignored like `.vscode-test/`. It keeps a directory per Positron version, so it grows as dailies move on — around 3 GB after one run, and worth clearing occasionally rather than a leak to fix. + +**Nothing in the integration job is cached in CI**, and both halves of that are deliberate. + +Positron is re-downloaded every run, which is not what the job costs: on `ubuntu-latest`, downloading it and running the suite together take about 75s. Caching `.positron-test/` would add an entry of roughly a gigabyte per Positron version, and `channel: 'daily'` mints a new version most days, which is how a cache becomes the problem instead of the fix. + +The kernel build is the expensive step — around 16 min cold against under 3 warm — and **the job gets it warm without owning a cache, by restoring `publish.yaml`'s.** That workflow runs on every PR as well, and builds this very binary with `cargo build --release --package ggsql-jupyter` on the same 1.86 toolchain and lockfile, so `shared-key: ${{ runner.os }}-publish` is an exact key match (`full match: true` in the log, restored in ~11s). `save-if: false` is the whole point: this job only ever reads, so it adds nothing to a 10 GB repo-wide limit that other workflows have already mostly spent — `build.yaml`'s cargo cache alone is over 5 GB. + +`rust-cache` prunes the workspace's own crates and keeps dependencies, so what comes back is the expensive part (duckdb, arrow, parquet) and only the final link is left to do. + +What that couples to is publish.yaml's `shared-key` and its toolchain. Rename either and this job silently goes cold, as it would if the entry were evicted — slower, never broken. The real fix would be to stop building the kernel twice per PR, but the two live in separate workflows and Actions artifacts are run-scoped, so nothing can pass between them; see [`/.github/workflows/test-extension.yaml`](../.github/workflows/test-extension.yaml). + +The assertions worth keeping: a runtime with `runtimeId` `ggsql-bundled` whose path is under `bundled/bin/`, its name matching `ggsql ` — the only check that the version really comes back from the binary — and `executeCode` returning a result against a session started from that id, which covers spawn, handshake and execution in one call. The suite starts the runtime by id rather than by language, because every ggsql install on the machine is registered and a developer running this locally has their own. + +### Testing discovery without wrecking the developer's machine + +Two seams exist because discovery reads and writes real state: + +- `GgsqlRuntimeManager` takes `{ kernelSpecDir }`. Discovery advertises the kernel by writing a Jupyter kernel spec, so a test that called `discoverAllRuntimes()` with the default would repoint the *real* kernelspec — the one Quarto resolves — at a temp fixture. +- `kernelDiscovery.test.ts` redirects `HOME`, `USERPROFILE`, `APPDATA`, `LOCALAPPDATA` and `PATH` to stage host kernels, restoring them in teardown. The native-installer locations (`/usr/local/bin`, `/usr/bin`, `/Applications`) are hard-coded absolutes that no environment variable can redirect, so any test asserting the whole list of candidates or runtimes — every install being offered, that is most of them — calls `systemInstallPresent()` and skips on a machine that has one. CI never does, which is where those regressions matter. -Add new tests as `src/test/.test.ts`; no config change is needed. +**Any test that calls `discoverAllRuntimes()` and asserts on the whole result must call `isolateHostEnv()` first**, not just the ones staging a host kernel. A count of probes or an assertion of "nothing was registered" is a claim about every kernel discovery can see, and on a developer's machine that includes their own installs. Running the extension is enough to create one: discovery writes the user kernelspec, so a session in the Extension Development Host leaves a kernel at `~/Library/Jupyter/kernels/ggsql/` that the suite then discovers. `systemInstallPresent()` does not cover this — it checks the native-installer paths only. ### Editing the grammar fixture diff --git a/ggsql-vscode/package-lock.json b/ggsql-vscode/package-lock.json index 8e8db899a..f621c0de5 100644 --- a/ggsql-vscode/package-lock.json +++ b/ggsql-vscode/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@posit-dev/positron": "^0.2.7", + "@posit-dev/positron-test-electron": "^0.0.3", "@types/mocha": "^10.0.10", "@types/node": "^18.x", "@types/vscode": "^1.75.0", @@ -765,6 +766,38 @@ "@types/vscode": "^1.74.0" } }, + "node_modules/@posit-dev/positron-test-electron": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@posit-dev/positron-test-electron/-/positron-test-electron-0.0.3.tgz", + "integrity": "sha512-MQYKCoB9JlGd70QLV0BVTezzGljIduLskeczDLVZ/4ECuYKAtuVFHvXFLDv5BUoarSxHkc0cT6r9k7DwrSni3A==", + "dev": true, + "dependencies": { + "@vscode/test-electron": "^2.4.1" + }, + "bin": { + "positron-test-electron": "out/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@posit-dev/positron-test-electron/node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/ggsql-vscode/package.json b/ggsql-vscode/package.json index f11f0b5f1..b388707c3 100644 --- a/ggsql-vscode/package.json +++ b/ggsql-vscode/package.json @@ -162,7 +162,7 @@ "ggsql.kernelPath": { "type": "string", "default": "", - "description": "Path to the ggsql-jupyter executable. If empty, uses 'ggsql-jupyter' from PATH." + "markdownDescription": "Path to a `ggsql-jupyter` executable to offer alongside the kernels found automatically, listed ahead of them. A bare name is looked up on `PATH`. Leave this empty unless you have a kernel in a place the extension does not look: it ships one, and it finds the ones installed on this machine." }, "ggsql.enableSqlFiles": { "type": "boolean", @@ -185,6 +185,7 @@ "pretest": "npm run compile-tests && npm run package", "test:grammar": "vscode-tmgrammar-test -g syntaxes/ggsql.tmLanguage.json \"src/test/grammar/*.gsql\"", "test:extension": "vscode-test", + "test:integration": "npm run compile-tests && npm run package && node ./out-test/test/runIntegration.js", "test": "npm run test:grammar && npm run test:extension" }, "dependencies": { @@ -192,6 +193,7 @@ }, "devDependencies": { "@posit-dev/positron": "^0.2.7", + "@posit-dev/positron-test-electron": "^0.0.3", "@types/mocha": "^10.0.10", "@types/node": "^18.x", "@types/vscode": "^1.75.0", diff --git a/ggsql-vscode/src/extension.ts b/ggsql-vscode/src/extension.ts index 435f26a0a..b9d1c6be3 100644 --- a/ggsql-vscode/src/extension.ts +++ b/ggsql-vscode/src/extension.ts @@ -23,6 +23,11 @@ export function log(message: string): void { outputChannel.appendLine(`[${new Date().toISOString()}] ${message}`); } +/** Reveal the ggsql output channel, for notifications that offer it. */ +export function showLog(): void { + outputChannel.show(); +} + /** * Activates the extension. * diff --git a/ggsql-vscode/src/manager.ts b/ggsql-vscode/src/manager.ts index 5c781118d..2e378b1ff 100644 --- a/ggsql-vscode/src/manager.ts +++ b/ggsql-vscode/src/manager.ts @@ -12,68 +12,124 @@ import * as cp from 'child_process'; import * as crypto from 'crypto'; import type * as positron from '@posit-dev/positron'; import type { JupyterKernelSpec, PositronSupervisorApi } from './types'; -import { log } from './extension'; +import { log, showLog } from './extension'; /** Where a kernel candidate was discovered */ -type KernelSource = 'Setting' | 'Jupyter' | 'System' | 'Path'; +type KernelSource = 'Bundled' | 'Setting' | 'Jupyter' | 'System' | 'Path'; /** * A discovered ggsql-jupyter kernel candidate */ -interface KernelCandidate { - /** Absolute path to the ggsql-jupyter binary (or bare name for PATH fallback) */ +export interface KernelCandidate { + /** Path to the ggsql-jupyter binary */ kernelPath: string; /** Human-readable label for where this was found */ source: KernelSource; } +/** What a kernel reported about itself when it was run. */ +export interface KernelInfo { + /** + * The version it printed for `--version`, absent when it printed none. + * Kernels older than the flag do not answer it. + */ + version?: string; +} + +/** A candidate that can serve a session, with what it reported about itself. */ +export interface RunnableKernel extends KernelCandidate, KernelInfo { } + +/** Platform-specific file name of the kernel executable */ +function kernelBinaryName(): string { + return process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter'; +} + /** - * Try to resolve a binary name to its absolute path via the system PATH. - * Returns the original value if resolution fails or the path is already absolute. + * Look a binary up on the system PATH. + * + * Returns undefined when it is not there. Callers must not fall back to the + * bare name: a bare name satisfies every existence check further down and so + * registers a runtime that cannot start. */ -function resolveToAbsolutePath(binaryPath: string): string { - if (path.isAbsolute(binaryPath)) { - return binaryPath; - } +function findOnPath(binaryName: string): string | undefined { try { const cmd = process.platform === 'win32' ? 'where' : 'which'; - const resolved = cp.execFileSync(cmd, [binaryPath], { + const resolved = cp.execFileSync(cmd, [binaryName], { encoding: 'utf8', timeout: 5000, }).trim().split(/\r?\n/)[0]; if (resolved && path.isAbsolute(resolved)) { - log(`Resolved '${binaryPath}' to '${resolved}'`); + log(`Resolved '${binaryName}' to '${resolved}'`); return resolved; } } catch { - log(`Could not resolve '${binaryPath}' to absolute path, using as-is`); + // which/where exit non-zero when the name is not on PATH } - return binaryPath; + log(`'${binaryName}' is not on PATH`); + return undefined; } /** - * Discover all available ggsql-jupyter kernel binaries + * Absolutise `ggsql.kernelPath`. * - * Checks in priority order: - * 1. Configured path in settings - * 2. Jupyter kernelspec locations (user and system) - * 3. Cargo-packager install locations - * 4. Fall back to PATH + * A bare name is looked up on PATH; if that fails the configured value is kept + * as-is, so that discovery rejects it as inaccessible and logs it back to the + * user rather than silently ignoring the setting. + */ +export function resolveConfiguredPath(configuredPath: string): string { + if (path.isAbsolute(configuredPath)) { + return configuredPath; + } + return findOnPath(configuredPath) ?? configuredPath; +} + +/** + * Restore the executable bit on the bundled kernel if it is missing. * - * Returns deduplicated candidates, keeping the highest-priority occurrence. + * `vsce` preserves the bit through package and install, so this should never + * fire; it is insurance against an unpack that drops it, which would otherwise + * present as the bundled kernel silently not being discovered. */ -function discoverKernelPaths(): KernelCandidate[] { - const candidates: KernelCandidate[] = []; - const binaryName = process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter'; +function ensureExecutable(binaryPath: string): void { + if (process.platform === 'win32') { + return; + } + try { + fs.accessSync(binaryPath, fs.constants.X_OK); + return; + } catch { + // Fall through and try to fix it + } + try { + fs.chmodSync(binaryPath, fs.statSync(binaryPath).mode | 0o111); + log(`Restored the executable bit on ${binaryPath}`); + } catch (err) { + log(`Could not make ${binaryPath} executable: ${err}`); + } +} - // 1. User-configured setting (highest priority) - const config = vscode.workspace.getConfiguration('ggsql'); - const configuredPath = config.get('kernelPath', ''); - if (configuredPath && configuredPath.trim() !== '') { - candidates.push({ kernelPath: configuredPath, source: 'Setting' }); +/** + * Path to the kernel shipped inside the extension, or undefined for a build + * that carries none (the platform-neutral VSIX). + */ +function bundledKernelPath(context: vscode.ExtensionContext): string | undefined { + const bundled = path.join(context.extensionPath, 'bundled', 'bin', kernelBinaryName()); + if (!fs.existsSync(bundled)) { + return undefined; } + ensureExecutable(bundled); + return bundled; +} + +/** + * Find kernels installed on the machine: Jupyter kernelspec locations, then the + * install locations of the native packages, then PATH. + */ +function discoverHostKernels(): KernelCandidate[] { + const candidates: KernelCandidate[] = []; + const binaryName = kernelBinaryName(); - // 2. Jupyter kernelspec locations + // Jupyter kernelspec locations const homeDir = process.env.HOME || process.env.USERPROFILE || ''; const kernelspecPaths = [ // User kernelspec (macOS) @@ -96,7 +152,7 @@ function discoverKernelPaths(): KernelCandidate[] { } } - // 3. Cargo-packager install locations + // Cargo-packager install locations const packagerPaths: string[] = []; if (process.platform === 'darwin') { // PKG installer (current) @@ -120,18 +176,47 @@ function discoverKernelPaths(): KernelCandidate[] { } } - // 4. PATH fallback (last resort) - candidates.push({ kernelPath: resolveToAbsolutePath(binaryName), source: 'Path' }); + // PATH, last of the host locations + const onPath = findOnPath(binaryName); + if (onPath) { + candidates.push({ kernelPath: onPath, source: 'Path' }); + } + + return candidates; +} - // Deduplicate by resolved absolute path +/** + * Order the places a kernel can come from. + * + * Every one of them is offered, so a machine with several ggsql installs shows + * them all and the user picks. Order decides which the picker lists first and, + * for two paths naming one file, which occurrence survives deduplication. + */ +export function selectKernelCandidates( + bundledPath: string | undefined, + configuredPath: string | undefined, + hostKernels: KernelCandidate[], +): KernelCandidate[] { + const candidates: KernelCandidate[] = []; + // A kernel the user named leads, ahead of the bundled default. + if (configuredPath) { + candidates.push({ kernelPath: configuredPath, source: 'Setting' }); + } + if (bundledPath) { + candidates.push({ kernelPath: bundledPath, source: 'Bundled' }); + } + candidates.push(...hostKernels); + return candidates; +} + +/** + * Drop candidates that name a file an earlier candidate already named, keeping + * the highest-priority occurrence. + */ +function dedupeCandidates(candidates: KernelCandidate[]): KernelCandidate[] { const seen = new Set(); const deduped: KernelCandidate[] = []; for (const candidate of candidates) { - if (!path.isAbsolute(candidate.kernelPath)) { - // Non-absolute paths (PATH fallback) can't be deduplicated - deduped.push(candidate); - continue; - } let resolved: string; try { resolved = fs.realpathSync(candidate.kernelPath); @@ -145,45 +230,292 @@ function discoverKernelPaths(): KernelCandidate[] { log(`Skipping duplicate kernel path: ${candidate.kernelPath} (resolves to ${resolved})`); } } - return deduped; } /** - * Check if a kernel executable exists and is accessible + * Discover the ggsql-jupyter kernels this window should offer, in priority + * order. */ -async function isKernelAccessible(kernelPath: string): Promise { - if (path.isAbsolute(kernelPath)) { +export function discoverKernelPaths(context: vscode.ExtensionContext): KernelCandidate[] { + const configuredPath = vscode.workspace + .getConfiguration('ggsql') + .get('kernelPath', '') + .trim(); + + return dedupeCandidates(selectKernelCandidates( + bundledKernelPath(context), + configuredPath === '' ? undefined : resolveConfiguredPath(configuredPath), + discoverHostKernels(), + )); +} + +/** + * Stat a candidate, requiring a file this process can execute. + * + * A path that is not absolute is rejected: discovery absolutises every source + * it can, so a bare name reaching here means the PATH lookup failed, and + * accepting it would register a runtime that fails at session start. + */ +async function statKernel(kernelPath: string): Promise { + if (!path.isAbsolute(kernelPath)) { + return undefined; + } + try { + const stats = await fs.promises.stat(kernelPath); + if (!stats.isFile()) { + return undefined; + } + await fs.promises.access(kernelPath, fs.constants.X_OK); + return stats; + } catch { + return undefined; + } +} + +/** Whether a candidate is a file this process can execute. */ +export async function isKernelAccessible(kernelPath: string): Promise { + return (await statKernel(kernelPath)) !== undefined; +} + +/** How long the probe waits for the kernel to report its version. */ +const KERNEL_PROBE_TIMEOUT_MS = 15000; + +/** Where successful probes are remembered, to keep them to one per install. */ +const PROBE_CACHE_KEY = 'ggsql.kernelProbes'; + +/** Where the dead-end notice records the version it has already reported. */ +const NO_KERNEL_NOTICE_KEY = 'ggsql.noUsableKernelNotice'; + +/** Install instructions offered when no kernel on this machine can run. */ +const INSTALL_DOCS_URL = 'https://ggsql.org/get_started/installation.html'; + +/** The version in the kernel's `--version` output, as in `ggsql-jupyter 0.4.1`. */ +const VERSION_PATTERN = /\b(\d+\.\d+\.\d+\S*)/; + +/** + * Runs a kernel binary and reports what it said about itself, or undefined when + * it did not run. + */ +export type KernelProbe = (kernelPath: string) => Promise; + +/** What running the kernel with one argument came to. */ +type RunOutcome = + /** It was exec'd, and either exited zero or did not. */ + | { exec: true; ok: boolean; stdout: string; reason: string } + /** It never started, so nothing can be concluded from its output. */ + | { exec: false; reason: string }; + +/** + * Run the kernel with a single argument and report how far it got. + * + * The distinction that matters is between a binary that never started and one + * that ran and exited non-zero: only the second says anything about the + * argument it was given. + */ +function runKernel(kernelPath: string, arg: string): Promise { + return new Promise(resolve => { + // On Windows a file that is not a valid executable fails the + // CreateProcess call itself, which Node surfaces as a synchronous + // throw from execFile (`spawn UNKNOWN`) rather than a callback error. try { - await fs.promises.access(kernelPath, fs.constants.X_OK); - return true; - } catch { - return false; + cp.execFile( + kernelPath, + [arg], + { timeout: KERNEL_PROBE_TIMEOUT_MS, windowsHide: true }, + (err, stdout) => { + if (!err) { + resolve({ exec: true, ok: true, stdout, reason: '' }); + } else if (typeof err.code === 'number') { + // An exit status, rather than one of the errno strings + // a failure to spawn reports, so the binary did start. + resolve({ exec: true, ok: false, stdout, reason: err.message }); + } else { + resolve({ exec: false, reason: err.message }); + } + }, + ); + } catch (err) { + resolve({ exec: false, reason: (err as Error).message }); } + }); +} + +/** + * Run the kernel and read the version it reports. + */ +export async function probeKernel(kernelPath: string): Promise { + const reportedVersion = await runKernel(kernelPath, '--version'); + if (!reportedVersion.exec) { + log(`Kernel probe failed for ${kernelPath}: ${reportedVersion.reason}`); + return undefined; + } + if (reportedVersion.ok) { + const version = VERSION_PATTERN.exec(reportedVersion.stdout)?.[1]; + if (!version) { + log(`Kernel at ${kernelPath} reported no version: ${reportedVersion.stdout.trim()}`); + } + return { version }; } - // For non-absolute paths (relying on PATH), always return true - // and let the actual kernel startup fail with a proper error message - return true; + // A kernel released before `--version` rejects the argument, which says + // nothing about whether it runs: one killed by the dynamic linker for want + // of a shared library exits non-zero in exactly the same way. `--help` is + // the question every version answers, so let that settle it rather than + // reading an exit status or matching the wording of an error. + const help = await runKernel(kernelPath, '--help'); + if (!help.exec || !help.ok) { + log(`Kernel probe failed for ${kernelPath}: ${help.reason}`); + return undefined; + } + log(`Kernel at ${kernelPath} predates \`--version\`, so is offered without one`); + return { version: undefined }; +} + +interface ProbeCacheEntry extends KernelInfo { + mtimeMs: number; + size: number; } /** - * Generate runtime metadata for a ggsql kernel candidate + * The successful probes remembered across windows, keyed by kernel path. + * + * A hit saves a spawn per kernel per window open. The file's mtime and size are + * part of the entry, so an install upgraded in place is probed again rather than + * reporting the version it used to have. A failure is not remembered: it is + * cheap to repeat, and a host that gains the shared libraries the bundled kernel + * needs should start working without waiting for an extension update. */ -function generateMetadata( - context: vscode.ExtensionContext, +class ProbeCache { + private readonly stored: Record; + private readonly used: Record = {}; + + constructor(private readonly context: vscode.ExtensionContext) { + this.stored = context.globalState.get>(PROBE_CACHE_KEY) ?? {}; + } + + async run(kernelPath: string, stats: fs.Stats, probe: KernelProbe): Promise { + const cached = this.stored[kernelPath]; + if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + this.used[kernelPath] = cached; + return { version: cached.version }; + } + + const info = await probe(kernelPath); + if (info) { + this.used[kernelPath] = { version: info.version, mtimeMs: stats.mtimeMs, size: stats.size }; + } + return info; + } + + /** + * Persist the probes this pass used, dropping any kernel it did not see — + * without which the paths of every superseded extension version would + * accumulate. + */ + async flush(): Promise { + if (JSON.stringify(this.used) !== JSON.stringify(this.stored)) { + await this.context.globalState.update(PROBE_CACHE_KEY, this.used); + } + } +} + +/** + * Decide whether a candidate can serve a session, and read its version. + */ +async function inspectKernel( candidate: KernelCandidate, -): positron.LanguageRuntimeMetadata { + cache: ProbeCache, + probe: KernelProbe, +): Promise { + const stats = await statKernel(candidate.kernelPath); + if (!stats) { + return undefined; + } + + const info = await cache.run(candidate.kernelPath, stats, probe); + if (info) { + return { ...candidate, ...info }; + } + // Only the bundled kernel has to prove it runs. It is built for this + // platform but not for every system it can be installed on, and that + // failure is invisible to the filesystem. A kernel the user installed is + // their own business, so it is still offered when it would not start. + return candidate.source === 'Bundled' ? undefined : { ...candidate }; +} + +/** + * Tell the user that nothing on this machine can run ggsql queries. + */ +function reportNoUsableKernel( + context: vscode.ExtensionContext, + bundledRejected: boolean, +): void { const version = context.extension.packageJSON.version as string; + if (context.globalState.get(NO_KERNEL_NOTICE_KEY) === version) { + return; + } + void context.globalState.update(NO_KERNEL_NOTICE_KEY, version); + + const reason = bundledRejected + ? 'The ggsql kernel bundled with this extension cannot run on this system.' + : 'This build of the ggsql extension does not include a kernel.'; + log(`${reason} No kernel installed on this machine could be used instead.`); + + const install = 'Install ggsql'; + const showOutput = 'Show Log'; + void vscode.window + .showWarningMessage(`${reason} Install ggsql to run queries.`, install, showOutput) + .then(choice => { + if (choice === install) { + void vscode.env.openExternal(vscode.Uri.parse(INSTALL_DOCS_URL)); + } else if (choice === showOutput) { + showLog(); + } + }); +} + +/** + * Stable runtime identifier for a candidate. + * + * Hashing the path gives one identifier per installed kernel, which is what + * Positron needs to keep runtime affinity and restorable sessions across + * windows. + */ +const BUNDLED_RUNTIME_ID = 'ggsql-bundled'; + +function runtimeIdFor(candidate: KernelCandidate): string { + if (candidate.source === 'Bundled') { + return BUNDLED_RUNTIME_ID; + } + const pathHash = crypto.createHash('sha256').update(candidate.kernelPath).digest('hex').substring(0, 12); + return `ggsql-${pathHash}`; +} + +/** + * Generate runtime metadata for a ggsql kernel + */ +export function generateMetadata( + context: vscode.ExtensionContext, + kernel: RunnableKernel, +): positron.LanguageRuntimeMetadata { + // The kernel is what runs the query, so its version is the one to show. A + // kernel too old to report one falls back to the extension's. + const version = kernel.version ?? context.extension.packageJSON.version as string; const iconPath = path.join(context.extensionPath, 'resources', 'ggsql-icon.svg'); const base64Icon = fs.readFileSync(iconPath).toString('base64'); - const pathHash = crypto.createHash('sha256').update(candidate.kernelPath).digest('hex').substring(0, 12); + // As Positron's own runtimes are named: the language, its version, and a + // qualifier saying which install this is. The bundled kernel is the default, + // so it carries no qualifier. + const named = kernel.version ? `ggsql ${kernel.version}` : 'ggsql'; + const runtimeName = kernel.source === 'Bundled' ? named : `${named} (${kernel.source})`; + return { - runtimeId: `ggsql-${pathHash}`, - runtimePath: candidate.kernelPath, - runtimeName: `ggsql (${candidate.source})`, + runtimeId: runtimeIdFor(kernel), + runtimePath: kernel.kernelPath, + runtimeName, runtimeShortName: 'ggsql', runtimeVersion: version, runtimeSource: 'ggsql', @@ -321,12 +653,6 @@ export function createDynState(sessionName?: string): positron.LanguageRuntimeDy /** * Get the Positron Supervisor API, activating the extension if needed. - * - * The supervisor is a soft dependency: it is declared nowhere in - * package.json, because an extensionDependencies entry would stop this - * extension activating at all in VS Code, where the supervisor does not - * exist. Awaiting activate() here gives the same ordering guarantee that a - * declared dependency would. */ export async function getSupervisorApi(): Promise { const supervisorExt = vscode.extensions.getExtension( @@ -340,6 +666,30 @@ export async function getSupervisorApi(): Promise { return supervisorExt.activate(); } +/** + * Overrides for GgsqlRuntimeManager's environment. + */ +export interface RuntimeManagerOptions { + /** + * Directory the discovered kernel is advertised in, as a Jupyter kernel + * spec. Defaults to the user-level Jupyter kernels directory. + * + * Discovery writes that spec as a side effect, so tests point this at a + * temp directory: otherwise running discovery would repoint the real + * kernelspec — the one Quarto and Jupyter resolve — at a test fixture. + */ + kernelSpecDir?: string; + + /** + * How a kernel is run to read its version. Defaults to running it. + * + * Tests override it because a stand-in kernel cannot be a real executable + * on every platform: a shell script named ggsql-jupyter.exe is not + * something Windows can spawn. + */ + probe?: KernelProbe; +} + /** * ggsql Language Runtime Manager * @@ -349,18 +699,17 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager { /** * Run discovery on every window open rather than trusting Positron's * cross-window cache. - * - * ggsql runtimes are not marked cacheable: the ggsql.kernelPath setting is - * workspace scoped, and the PATH fallback is not guaranteed to resolve to - * a real file. A cache hit would therefore register only some of the - * candidates and silently hide the rest on warm starts. */ public readonly alwaysRediscover = true; private _context: vscode.ExtensionContext; + private _kernelSpecDir: string; + private _probe: KernelProbe; - constructor(context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext, options: RuntimeManagerOptions = {}) { this._context = context; + this._kernelSpecDir = options.kernelSpecDir ?? getUserJupyterKernelDir(); + this._probe = options.probe ?? probeKernel; } /** @@ -370,29 +719,48 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager { */ discoverAllRuntimes(): AsyncGenerator { const context = this._context; + const kernelSpecDir = this._kernelSpecDir; + const probe = this._probe; const generator = async function* discoverGgsqlRuntimes() { log('Discovering ggsql runtimes...'); - const candidates = discoverKernelPaths(); + const candidates = discoverKernelPaths(context); log(`Found ${candidates.length} kernel candidate(s)`); + const cache = new ProbeCache(context); + let registered = 0; + let bundledRejected = false; + for (const candidate of candidates) { - const accessible = await isKernelAccessible(candidate.kernelPath); - if (accessible) { - // When a system install is found, write the kernel spec to - // the user kernelspec dir immediately so that Quarto/Jupyter - // can discover ggsql even if no session is ever started. - if (candidate.source === 'System') { - writeKernelJson(getUserJupyterKernelDir(), candidate.kernelPath); + const kernel = await inspectKernel(candidate, cache, probe); + if (!kernel) { + if (candidate.source === 'Bundled') { + bundledRejected = true; } + log(`Skipping unusable kernel (${candidate.source}): ${candidate.kernelPath}`); + continue; + } - const metadata = generateMetadata(context, candidate); - log(`Yielding runtime: ${metadata.runtimeName} (${metadata.runtimeId}) at ${candidate.kernelPath}`); - yield metadata; - } else { - log(`Skipping inaccessible kernel: ${candidate.kernelPath}`); + // Advertise the leading kernel as a Jupyter kernel spec, so that + // Quarto and Jupyter can discover ggsql even if no session is + // ever started, and so that the spec stops pointing into an + // extension directory an update has removed. A kernel found as a + // kernelspec is already advertised where Jupyter looks. + if (registered === 0 && kernel.source !== 'Jupyter') { + writeKernelJson(kernelSpecDir, kernel.kernelPath); } + + const metadata = generateMetadata(context, kernel); + log(`Yielding runtime: ${metadata.runtimeName} (${metadata.runtimeId}) at ${kernel.kernelPath}`); + registered++; + yield metadata; + } + + await cache.flush(); + + if (registered === 0) { + reportNoUsableKernel(context, bundledRejected); } log('Runtime discovery complete'); @@ -401,6 +769,36 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager { return generator(); } + /** + * Refresh metadata Positron stored for this workspace. + * + * The bundled kernel's `runtimePath` names the versioned extension + * directory, which an update removes; its `runtimeId` is fixed precisely so + * that the runtime survives, which it only does if the path is regenerated + * here. A kernel that has since been uninstalled has no candidate to match, + * and rejecting it is how Positron learns to drop it. + */ + async validateMetadata( + metadata: positron.LanguageRuntimeMetadata, + ): Promise { + // Deliberately not flushed: this walks only as far as the match, so + // persisting the pass would drop the probes discovery cached. + const cache = new ProbeCache(this._context); + + for (const candidate of discoverKernelPaths(this._context)) { + if (runtimeIdFor(candidate) !== metadata.runtimeId) { + continue; + } + const kernel = await inspectKernel(candidate, cache, this._probe); + if (kernel) { + return generateMetadata(this._context, kernel); + } + break; + } + + throw new Error(`No usable ggsql kernel for runtime ${metadata.runtimeId}`); + } + /** * Get the recommended runtime for the workspace. * diff --git a/ggsql-vscode/src/test/integration/console.test.ts b/ggsql-vscode/src/test/integration/console.test.ts new file mode 100644 index 000000000..c000b246f --- /dev/null +++ b/ggsql-vscode/src/test/integration/console.test.ts @@ -0,0 +1,103 @@ +/* + * End-to-end check that the kernel bundled in this extension actually runs. + * + * Everything else about bundling is verified without a kernel process: the unit + * suites assert precedence and metadata, and the release workflow asserts the + * binary is inside the VSIX. Neither can tell whether the binary starts. This + * suite does, which is the failure the whole change is about (`KS-19: Kernel + * path not found`, and its cousins — a wrong-architecture or unsigned binary + * that Positron cannot launch). + * + * Requires ggsql-vscode/bundled/bin/ggsql-jupyter to exist; CI builds it first. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { PositronApi } from '@posit-dev/positron'; +import { getPositronApi } from '../../positronApi'; + +const EXTENSION_ID = 'ggsql.ggsql'; + +/** Poll until `probe` returns a value, or fail after `timeoutMs`. */ +async function waitFor(what: string, timeoutMs: number, probe: () => Promise): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = await probe(); + if (found !== undefined) { + return found; + } + if (Date.now() > deadline) { + throw new Error(`timed out after ${timeoutMs}ms waiting for ${what}`); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } +} + +suite('bundled kernel in Positron', () => { + let positron: PositronApi; + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `extension ${EXTENSION_ID} not found`); + await extension.activate(); + + const api = getPositronApi(); + assert.ok(api, 'no Positron API; this suite must run under Positron, not VS Code'); + positron = api; + }); + + test('the bundled kernel is registered, named for the version it reports', async () => { + // Discovery runs on window open, so the runtime may not be registered the + // instant activation returns. + const runtimes = await waitFor('a registered ggsql runtime', 60_000, async () => { + const registered = await positron.runtime.getRegisteredRuntimes(); + const ggsql = registered.filter(runtime => runtime.languageId === 'ggsql'); + return ggsql.length > 0 ? ggsql : undefined; + }); + + // Every ggsql install on the machine is offered, so this asserts about + // the bundled one rather than the size of the list — a developer running + // the suite locally has ggsql installed as well. + const bundled = runtimes.filter(runtime => runtime.runtimeId === 'ggsql-bundled'); + assert.strictEqual( + bundled.length, + 1, + `expected one bundled ggsql runtime, got ${runtimes.map(r => r.runtimePath).join(', ')}`, + ); + assert.ok( + bundled[0].runtimePath.includes(path.join('bundled', 'bin')), + `unexpected kernel path ${bundled[0].runtimePath}`, + ); + // The version comes from running the binary, so this is the one place + // the interpolation is checked against a real kernel. + assert.match(bundled[0].runtimeName, /^ggsql \d+\.\d+\.\d+/); + }); + + test('the console starts the bundled kernel and runs a query', async () => { + // Starting by runtime id, rather than letting Positron choose one for the + // language, is what makes this a test of the kernel inside the VSIX: + // every ggsql install is offered now, so the default is not necessarily + // the bundled one. + const session = await positron.runtime.startLanguageRuntime('ggsql-bundled', 'ggsql'); + assert.strictEqual(session.runtimeMetadata.runtimeId, 'ggsql-bundled'); + + // executeCode goes to that session, so this covers the whole path: + // spawning the binary, the supervisor's Jupyter handshake, and a result + // coming back. The kernel holds an in-memory DuckDB session, so the + // query needs no connection string. + const result = await positron.runtime.executeCode('ggsql', 'SELECT 1 AS n', false); + assert.ok(result, 'executeCode returned no result'); + }); + + test('a query with a visualisation returns a plot', async () => { + // The reason a ggsql console exists, and a second execution on the + // session the previous test started. + const result = await positron.runtime.executeCode( + 'ggsql', + 'SELECT 1 AS x, 2 AS y VISUALISE x AS x, y AS y DRAW point', + false, + ); + assert.ok(result, 'executeCode returned no result'); + }); +}); diff --git a/ggsql-vscode/src/test/integration/index.ts b/ggsql-vscode/src/test/integration/index.ts new file mode 100644 index 000000000..73b1aed22 --- /dev/null +++ b/ggsql-vscode/src/test/integration/index.ts @@ -0,0 +1,42 @@ +/* + * Entry point for the Positron integration suite. + * + * @posit-dev/positron-test-electron launches Positron and requires this module + * inside its extension host, so the suite drives mocha itself rather than going + * through @vscode/test-cli the way the stock VS Code suites do. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import Mocha from 'mocha'; + +export function run(): Promise { + const mocha = new Mocha({ + ui: 'tdd', + color: true, + // Starting a session launches the kernel binary and completes a Jupyter + // handshake over ZeroMQ, which is far slower than anything the stock + // suites do. + timeout: 120_000, + }); + + for (const file of fs.readdirSync(__dirname)) { + if (file.endsWith('.test.js')) { + mocha.addFile(path.join(__dirname, file)); + } + } + + return new Promise((resolve, reject) => { + try { + mocha.run(failures => { + if (failures > 0) { + reject(new Error(`${failures} integration test(s) failed`)); + } else { + resolve(); + } + }); + } catch (err) { + reject(err); + } + }); +} diff --git a/ggsql-vscode/src/test/kernelDiscovery.test.ts b/ggsql-vscode/src/test/kernelDiscovery.test.ts new file mode 100644 index 000000000..337f55622 --- /dev/null +++ b/ggsql-vscode/src/test/kernelDiscovery.test.ts @@ -0,0 +1,868 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type * as positron from '@posit-dev/positron'; +import { + GgsqlRuntimeManager, + discoverKernelPaths, + generateMetadata, + isKernelAccessible, + probeKernel, + resolveConfiguredPath, + selectKernelCandidates, + type KernelCandidate, + type KernelProbe, +} from '../manager'; + +const EXTENSION_ID = 'ggsql.ggsql'; + +// Directories created by the helpers below, removed in suiteTeardown. +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ggsql-kernel-')); + tempDirs.push(dir); + return dir; +} + +const binaryName = process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter'; + +/** The version the stubbed probe reports, standing in for a real kernel's. */ +const STUB_VERSION = '1.2.3'; + +function realExtension(): vscode.Extension { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `extension ${EXTENSION_ID} not found`); + return extension; +} + +/** Write an executable stand-in for the kernel into `dir`. */ +function writeStubKernel(dir: string, mode = 0o755, script = '#!/bin/sh\nexit 0\n'): string { + fs.mkdirSync(dir, { recursive: true }); + const kernelPath = path.join(dir, binaryName); + fs.writeFileSync(kernelPath, script); + fs.chmodSync(kernelPath, mode); + return kernelPath; +} + +/** + * Build a directory that looks like an installed platform-neutral VSIX: no + * kernel, but the icon generateMetadata reads from the extension folder. + */ +function extensionDir(): string { + const extensionPath = tempDir(); + fs.mkdirSync(path.join(extensionPath, 'resources'), { recursive: true }); + fs.copyFileSync( + path.join(realExtension().extensionPath, 'resources', 'ggsql-icon.svg'), + path.join(extensionPath, 'resources', 'ggsql-icon.svg'), + ); + return extensionPath; +} + +/** The same, as a platform VSIX: with a kernel at bundled/bin/. */ +function extensionDirWithBundle(mode = 0o755): { extensionPath: string; kernelPath: string } { + const extensionPath = extensionDir(); + return { extensionPath, kernelPath: writeStubKernel(path.join(extensionPath, 'bundled', 'bin'), mode) }; +} + +/** An in-memory stand-in for context.globalState, which the probe cache uses. */ +function memoryState(): vscode.Memento { + const store = new Map(); + return { + keys: () => [...store.keys()], + get: (key: string, defaultValue?: unknown) => + store.has(key) ? store.get(key) : defaultValue, + update: async (key: string, value: unknown) => { + store.set(key, value); + }, + } as unknown as vscode.Memento; +} + +function contextFor(extensionPath: string, globalState = memoryState()): vscode.ExtensionContext { + return { + extensionPath, + globalState, + extension: { packageJSON: { version: realExtension().packageJSON.version } }, + } as unknown as vscode.ExtensionContext; +} + +/** + * True when a native installer has put a kernel on this machine. Those paths are + * hard-coded absolutes that no environment variable can redirect, so a test + * needing "no host kernel anywhere" has to stand aside on such a machine. CI + * never has one, which is where the regression matters. + */ +function systemInstallPresent(): boolean { + return [ + '/usr/local/bin/ggsql-jupyter', + '/usr/bin/ggsql-jupyter', + '/Applications/ggsql.app/Contents/MacOS/ggsql-jupyter', + path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'ggsql', 'ggsql-jupyter.exe'), + ].some(p => fs.existsSync(p)); +} + +// Environment host discovery reads. Saved and restored around any test that +// redirects it, so no other suite sees a doctored environment. +const HOST_ENV_KEYS = ['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PATH'] as const; +let savedEnv: Partial> = {}; + +function isolateHostEnv(homeDir: string): void { + for (const key of HOST_ENV_KEYS) { + savedEnv[key] = process.env[key]; + } + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + process.env.APPDATA = path.join(homeDir, 'AppData', 'Roaming'); + process.env.LOCALAPPDATA = path.join(homeDir, 'AppData', 'Local'); + // An empty directory as PATH makes the which/where lookup fail, so whatever + // the developer has installed cannot contribute a candidate. + process.env.PATH = tempDir(); +} + +function restoreHostEnv(): void { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + savedEnv = {}; +} + +const HOST: KernelCandidate[] = [ + { kernelPath: '/usr/local/bin/ggsql-jupyter', source: 'System' }, + { kernelPath: '/opt/homebrew/bin/ggsql-jupyter', source: 'Path' }, +]; + +suite('kernel candidate selection', () => { + const bundled = '/ext/ggsql.ggsql-0.5.0-darwin-arm64/bundled/bin/ggsql-jupyter'; + const configured = '/opt/ggsql/ggsql-jupyter'; + + test('every kernel found is offered, bundled ahead of installed ones', () => { + // A machine with several ggsql installs shows all of them and lets the + // user pick; the bundled one leads, which makes it the default. + assert.deepStrictEqual(selectKernelCandidates(bundled, undefined, HOST), [ + { kernelPath: bundled, source: 'Bundled' }, + ...HOST, + ]); + }); + + test('a configured kernel leads the list', () => { + // The user named a binary, so it is the one to offer first — but it no + // longer suppresses the others. + assert.deepStrictEqual(selectKernelCandidates(bundled, configured, HOST), [ + { kernelPath: configured, source: 'Setting' }, + { kernelPath: bundled, source: 'Bundled' }, + ...HOST, + ]); + }); + + test('a build that carries no kernel offers the installed ones', () => { + // The platform-neutral VSIX ships no kernel and must keep working + // through a host install. + assert.deepStrictEqual(selectKernelCandidates(undefined, undefined, HOST), HOST); + }); + + test('no bundle and no host kernel yields no candidates', () => { + // Regression test for the phantom runtime: a ggsql runtime registered + // against a kernel that is not there fails at session start with KS-19. + assert.deepStrictEqual(selectKernelCandidates(undefined, undefined, []), []); + }); +}); + +suite('discovery from real settings', () => { + // Only discoverKernelPaths proves the setting actually reaches the + // precedence rule. + const config = () => vscode.workspace.getConfiguration('ggsql'); + + teardown(async () => { + await config().update('kernelPath', undefined, vscode.ConfigurationTarget.Global); + }); + + test('a configured kernelPath is offered ahead of the bundled kernel', async () => { + const configured = writeStubKernel(tempDir()); + await config().update('kernelPath', configured, vscode.ConfigurationTarget.Global); + + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const candidates = discoverKernelPaths(contextFor(extensionPath)); + assert.deepStrictEqual(candidates.slice(0, 2), [ + { kernelPath: configured, source: 'Setting' }, + { kernelPath, source: 'Bundled' }, + ]); + }); + + test('an unset kernelPath contributes no candidate', () => { + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const candidates = discoverKernelPaths(contextFor(extensionPath)); + assert.strictEqual(candidates[0].kernelPath, kernelPath); + assert.ok( + !candidates.some(candidate => candidate.source === 'Setting'), + 'an empty setting produced a candidate', + ); + }); +}); + +suite('resolving a configured kernel path', () => { + test('an absolute path is used as given', () => { + const configured = path.join(tempDir(), binaryName); + assert.strictEqual(resolveConfiguredPath(configured), configured); + }); + + test('a bare name is looked up on PATH', () => { + const name = process.platform === 'win32' ? 'cmd.exe' : 'sh'; + assert.ok( + path.isAbsolute(resolveConfiguredPath(name)), + `${name} did not resolve to an absolute path`, + ); + }); + + test('a bare name that is not on PATH is kept, then rejected', async () => { + // Kept rather than dropped so that discovery reports the user's setting + // as inaccessible instead of ignoring it without a word. + const name = 'ggsql-jupyter-not-a-real-binary'; + assert.strictEqual(resolveConfiguredPath(name), name); + assert.strictEqual(await isKernelAccessible(name), false); + }); +}); + +suite('kernel accessibility', () => { + test('a bare binary name is not accessible', async () => { + // Anything non-absolute reaching this check means the PATH lookup + // failed; accepting it is the other half of the phantom runtime. + assert.strictEqual(await isKernelAccessible(binaryName), false); + }); + + test('an executable file is accessible', async () => { + assert.strictEqual(await isKernelAccessible(writeStubKernel(tempDir())), true); + }); + + test('a missing file is not accessible', async () => { + assert.strictEqual(await isKernelAccessible(path.join(tempDir(), binaryName)), false); + }); + + test('a directory is not accessible', async () => { + // Directories carry the executable bit on POSIX, so an access() check + // on its own would pass one. + assert.strictEqual(await isKernelAccessible(tempDir()), false); + }); +}); + +suite('bundled kernel discovery', () => { + test('the bundled kernel leads the candidates', () => { + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const candidates = discoverKernelPaths(contextFor(extensionPath)); + assert.deepStrictEqual(candidates[0], { kernelPath, source: 'Bundled' }); + }); + + test('a bundled kernel missing its executable bit is repaired', function () { + // Insurance against an unpack that drops the bit: without the repair the + // binary would be dropped as inaccessible and no runtime would appear. + if (process.platform === 'win32') { + this.skip(); + } + const { extensionPath, kernelPath } = extensionDirWithBundle(0o644); + const candidates = discoverKernelPaths(contextFor(extensionPath)); + assert.deepStrictEqual(candidates[0], { kernelPath, source: 'Bundled' }); + assert.ok(fs.statSync(kernelPath).mode & 0o111, 'the executable bit was not restored'); + }); +}); + +suite('host kernel discovery', () => { + let home: string; + + setup(() => { + home = tempDir(); + isolateHostEnv(home); + }); + + teardown(() => { + restoreHostEnv(); + }); + + test('a user Jupyter kernelspec is found when the build has no kernel', function () { + if (systemInstallPresent()) { + this.skip(); + } + const kernel = writeStubKernel(path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql')); + const candidates = discoverKernelPaths(contextFor(tempDir())); + assert.deepStrictEqual(candidates, [{ kernelPath: kernel, source: 'Jupyter' }]); + for (const candidate of candidates) { + assert.ok(path.isAbsolute(candidate.kernelPath), `${candidate.kernelPath} is not absolute`); + assert.ok(fs.existsSync(candidate.kernelPath), `${candidate.kernelPath} does not exist`); + } + }); + + test('one kernel reachable by two paths is reported once', function () { + // The realistic duplicate is a kernelspec symlinked to the installed + // binary. Both the macOS and Linux kernelspec locations are checked on + // every platform, so two of them can name one file. + if (process.platform === 'win32' || systemInstallPresent()) { + this.skip(); + } + const real = writeStubKernel(path.join(home, 'opt')); + for (const dir of [ + path.join(home, 'Library', 'Jupyter', 'kernels', 'ggsql'), + path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql'), + ]) { + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync(real, path.join(dir, binaryName)); + } + const candidates = discoverKernelPaths(contextFor(tempDir())); + assert.strictEqual( + candidates.length, + 1, + `expected one candidate, got ${candidates.map(c => c.kernelPath).join(', ')}`, + ); + }); + + test('an installed kernel sits behind the bundled one', function () { + if (systemInstallPresent()) { + this.skip(); + } + const hostKernel = writeStubKernel(path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql')); + const { extensionPath, kernelPath } = extensionDirWithBundle(); + assert.deepStrictEqual(discoverKernelPaths(contextFor(extensionPath)), [ + { kernelPath, source: 'Bundled' }, + { kernelPath: hostKernel, source: 'Jupyter' }, + ]); + }); +}); + +suite('runtime registration', () => { + async function collect( + runtimes: AsyncGenerator, + ): Promise { + const collected: positron.LanguageRuntimeMetadata[] = []; + for await (const runtime of runtimes) { + collected.push(runtime); + } + return collected; + } + + /** + * A manager over a stand-in extension directory. + * + * The probe defaults to reporting a version: a stand-in kernel cannot be a + * real executable on every platform, so running one for real is left to the + * `kernel probe` suite and these tests inject the verdict instead. + */ + function managerFor( + extensionPath: string, + kernelSpecDir: string, + options: { probe?: KernelProbe; globalState?: vscode.Memento } = {}, + ): { manager: GgsqlRuntimeManager; globalState: vscode.Memento } { + const globalState = options.globalState ?? memoryState(); + const manager = new GgsqlRuntimeManager(contextFor(extensionPath, globalState), { + kernelSpecDir, + probe: options.probe ?? (async () => ({ version: STUB_VERSION })), + }); + return { manager, globalState }; + } + + /** The key reportNoUsableKernel stamps once it has warned for this version. */ + const NOTICE_KEY = 'ggsql.noUsableKernelNotice'; + + // The dead-end notice is fire-and-forget, so it is captured rather than + // awaited. Stubbing it also keeps the suite from raising real notifications + // in the test window. + let warnings: string[] = []; + let realShowWarningMessage: typeof vscode.window.showWarningMessage; + + setup(() => { + warnings = []; + realShowWarningMessage = vscode.window.showWarningMessage; + (vscode.window as unknown as Record).showWarningMessage = + (message: string) => { + warnings.push(message); + return Promise.resolve(undefined); + }; + }); + + teardown(() => { + (vscode.window as unknown as Record).showWarningMessage = + realShowWarningMessage; + }); + + test('the bundled kernel is registered under the version it reports', async () => { + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const runtimes = await collect(managerFor(extensionPath, tempDir()).manager.discoverAllRuntimes()); + assert.strictEqual(runtimes[0].runtimeId, 'ggsql-bundled'); + assert.strictEqual(runtimes[0].runtimePath, kernelPath); + assert.strictEqual(runtimes[0].runtimeName, `ggsql ${STUB_VERSION}`); + }); + + test('every runnable kernel is registered, bundled first', async function () { + // The picker shows each ggsql install on the machine rather than only + // the extension's own, which is how a user keeps using theirs. + if (systemInstallPresent()) { + this.skip(); + } + const home = tempDir(); + isolateHostEnv(home); + try { + const hostKernel = writeStubKernel( + path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql'), + ); + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const kernelSpecDir = tempDir(); + const runtimes = await collect( + managerFor(extensionPath, kernelSpecDir).manager.discoverAllRuntimes(), + ); + + assert.deepStrictEqual( + runtimes.map(runtime => [runtime.runtimeName, runtime.runtimePath]), + [ + [`ggsql ${STUB_VERSION}`, kernelPath], + [`ggsql ${STUB_VERSION} (Jupyter)`, hostKernel], + ], + ); + // Every runtime is a distinct one as far as Positron is concerned. + assert.strictEqual(new Set(runtimes.map(runtime => runtime.runtimeId)).size, 2); + // One spec is written, for the kernel that leads the list. + const spec = JSON.parse(fs.readFileSync(path.join(kernelSpecDir, 'kernel.json'), 'utf8')); + assert.strictEqual(spec.argv[0], kernelPath); + } finally { + restoreHostEnv(); + } + }); + + test('discovery advertises the leading kernel to Jupyter', async () => { + // Quarto and Jupyter resolve ggsql through this spec. It is rewritten on + // every window open because an extension update leaves the previous one + // pointing into a directory that no longer exists. + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const kernelSpecDir = tempDir(); + await collect(managerFor(extensionPath, kernelSpecDir).manager.discoverAllRuntimes()); + const spec = JSON.parse(fs.readFileSync(path.join(kernelSpecDir, 'kernel.json'), 'utf8')); + assert.strictEqual(spec.argv[0], kernelPath); + assert.strictEqual(spec.language, 'ggsql'); + }); + + test('a bundled path that is not an executable file registers nothing', async () => { + // The accessibility filter is what stands between a broken bundle and a + // runtime that fails at session start. A directory where the binary + // should be exists and carries the executable bit, so only the isFile() + // check rejects it — and no kernel spec may be written either. + const extensionPath = tempDir(); + fs.mkdirSync(path.join(extensionPath, 'resources'), { recursive: true }); + fs.copyFileSync( + path.join(realExtension().extensionPath, 'resources', 'ggsql-icon.svg'), + path.join(extensionPath, 'resources', 'ggsql-icon.svg'), + ); + fs.mkdirSync(path.join(extensionPath, 'bundled', 'bin', binaryName), { recursive: true }); + + // "Registers nothing" is a claim about every runtime discovery yields, + // so the developer's own installs have to be out of the picture. + isolateHostEnv(tempDir()); + try { + const kernelSpecDir = tempDir(); + const runtimes = await collect( + managerFor(extensionPath, kernelSpecDir).manager.discoverAllRuntimes(), + ); + assert.deepStrictEqual(runtimes, []); + assert.strictEqual(fs.existsSync(path.join(kernelSpecDir, 'kernel.json')), false); + } finally { + restoreHostEnv(); + } + }); + + test('a bundled kernel that cannot run leaves the installed one registered', async function () { + // The bundled kernel is built for the platform, not for every system on + // it: one built against newer shared libraries than the host provides + // execs and then dies under the dynamic linker. Nothing on the + // filesystem shows that, so the host install has to remain usable. + if (systemInstallPresent()) { + this.skip(); + } + const home = tempDir(); + isolateHostEnv(home); + try { + const hostKernel = writeStubKernel( + path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql'), + ); + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const { manager } = managerFor(extensionPath, tempDir(), { + probe: async candidate => + candidate === kernelPath ? undefined : { version: STUB_VERSION }, + }); + + const runtimes = await collect(manager.discoverAllRuntimes()); + assert.strictEqual(runtimes.length, 1); + assert.strictEqual(runtimes[0].runtimePath, hostKernel); + // Named for where it came from, which is how the handover is + // disclosed without interrupting the user. + assert.strictEqual(runtimes[0].runtimeName, `ggsql ${STUB_VERSION} (Jupyter)`); + // A fallback that works is not worth interrupting anyone over. + assert.deepStrictEqual(warnings, []); + } finally { + restoreHostEnv(); + } + }); + + test('an installed kernel too old to report a version is still registered', async function () { + // Kernels released before the --version flag exit non-zero on it. Only + // the bundled kernel has to pass the probe; dropping the others would + // take away the install the user already had. + if (systemInstallPresent()) { + this.skip(); + } + const home = tempDir(); + isolateHostEnv(home); + try { + const hostKernel = writeStubKernel( + path.join(home, '.local', 'share', 'jupyter', 'kernels', 'ggsql'), + ); + const { manager } = managerFor(extensionDir(), tempDir(), { probe: async () => undefined }); + + const runtimes = await collect(manager.discoverAllRuntimes()); + assert.strictEqual(runtimes.length, 1); + assert.strictEqual(runtimes[0].runtimePath, hostKernel); + // No version to interpolate, so the name says only where it came from. + assert.strictEqual(runtimes[0].runtimeName, 'ggsql (Jupyter)'); + assert.deepStrictEqual(warnings, []); + } finally { + restoreHostEnv(); + } + }); + + test('a bundled kernel that cannot run is not advertised to Jupyter', async function () { + // The kernel spec outlives the window and is what Quarto resolves, so + // pointing it at a binary that does not run would break tools that + // never see this extension's other candidates. + if (systemInstallPresent()) { + this.skip(); + } + isolateHostEnv(tempDir()); + try { + const { extensionPath } = extensionDirWithBundle(); + const kernelSpecDir = tempDir(); + const { manager } = managerFor(extensionPath, kernelSpecDir, { + probe: async () => undefined, + }); + + const runtimes = await collect(manager.discoverAllRuntimes()); + assert.deepStrictEqual(runtimes, []); + assert.strictEqual(fs.existsSync(path.join(kernelSpecDir, 'kernel.json')), false); + } finally { + restoreHostEnv(); + } + }); + + test('a bundled kernel that cannot run and no installed one warns once', async function () { + if (systemInstallPresent()) { + this.skip(); + } + isolateHostEnv(tempDir()); + try { + const { extensionPath } = extensionDirWithBundle(); + const globalState = memoryState(); + + const first = managerFor(extensionPath, tempDir(), { + probe: async () => undefined, + globalState, + }); + assert.deepStrictEqual(await collect(first.manager.discoverAllRuntimes()), []); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /cannot run on this system/); + assert.strictEqual(globalState.get(NOTICE_KEY), realExtension().packageJSON.version); + + // Discovery runs on every window open; the notice must not repeat. + const second = managerFor(extensionPath, tempDir(), { + probe: async () => undefined, + globalState, + }); + assert.deepStrictEqual(await collect(second.manager.discoverAllRuntimes()), []); + assert.strictEqual(warnings.length, 1, 'the dead-end notice was shown twice'); + } finally { + restoreHostEnv(); + } + }); + + test('a kernel that reported its version is not re-probed on the next window', async () => { + const { extensionPath } = extensionDirWithBundle(); + const globalState = memoryState(); + let probes = 0; + const probe: KernelProbe = async () => { + probes++; + return { version: STUB_VERSION }; + }; + + // The count is per kernel, so it only means one thing if the bundled + // kernel is the only one discovery can see. + isolateHostEnv(tempDir()); + try { + const first = managerFor(extensionPath, tempDir(), { probe, globalState }); + assert.strictEqual((await collect(first.manager.discoverAllRuntimes()))[0].runtimeVersion, STUB_VERSION); + assert.strictEqual(probes, 1); + + const second = managerFor(extensionPath, tempDir(), { probe, globalState }); + assert.strictEqual((await collect(second.manager.discoverAllRuntimes()))[0].runtimeVersion, STUB_VERSION); + assert.strictEqual(probes, 1, 'the kernel was probed again'); + } finally { + restoreHostEnv(); + } + }); + + test('a kernel replaced in place is probed again', async () => { + // An installer upgrading a kernel leaves its path alone, so a cache + // keyed on the path alone would keep reporting the old version. + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const globalState = memoryState(); + let version = '1.0.0'; + const probe: KernelProbe = async () => ({ version }); + + const first = managerFor(extensionPath, tempDir(), { probe, globalState }); + assert.strictEqual((await collect(first.manager.discoverAllRuntimes()))[0].runtimeVersion, '1.0.0'); + + fs.appendFileSync(kernelPath, '# upgraded\n'); + version = '2.0.0'; + + const second = managerFor(extensionPath, tempDir(), { probe, globalState }); + assert.strictEqual((await collect(second.manager.discoverAllRuntimes()))[0].runtimeVersion, '2.0.0'); + }); + + test('a build with no kernel and nothing installed warns too', async function () { + // The platform-neutral VSIX carries no kernel at all. That dead end + // is the same one, and gets the same notice. + if (systemInstallPresent()) { + this.skip(); + } + isolateHostEnv(tempDir()); + try { + const globalState = memoryState(); + const { manager } = managerFor(tempDir(), tempDir(), { globalState }); + assert.deepStrictEqual(await collect(manager.discoverAllRuntimes()), []); + assert.strictEqual(warnings.length, 1); + // Worded for a build that never carried a kernel, not a broken one. + assert.match(warnings[0], /does not include a kernel/); + assert.strictEqual(globalState.get(NOTICE_KEY), realExtension().packageJSON.version); + } finally { + restoreHostEnv(); + } + }); + + test('a machine with no kernel at all registers nothing', async function () { + if (systemInstallPresent()) { + this.skip(); + } + // The W6 requirement stated in terms of what Positron receives, rather + // than what the precedence rule returns. + isolateHostEnv(tempDir()); + try { + const runtimes = await collect(managerFor(tempDir(), tempDir()).manager.discoverAllRuntimes()); + assert.deepStrictEqual(runtimes, []); + } finally { + restoreHostEnv(); + } + }); +}); + +suite('runtime metadata', () => { + // generateMetadata reads resources/ggsql-icon.svg from the extension folder, + // so these use the real one. + const context = () => contextFor(realExtension().extensionPath); + + test('the bundled runtime id survives an extension update', () => { + // The bundled kernel lives under the versioned extension directory. An + // id derived from that path would change on every update, dropping the + // workspace's runtime affinity and its restorable sessions. + const before = generateMetadata(context(), { + kernelPath: '/ext/ggsql.ggsql-0.5.0-darwin-arm64/bundled/bin/ggsql-jupyter', + source: 'Bundled', + version: '0.5.0', + }); + const after = generateMetadata(context(), { + kernelPath: '/ext/ggsql.ggsql-0.6.0-darwin-arm64/bundled/bin/ggsql-jupyter', + source: 'Bundled', + version: '0.6.0', + }); + assert.strictEqual(before.runtimeId, after.runtimeId); + }); + + test('the bundled runtime is named for the version the kernel reports', () => { + // As Positron names its own runtimes, and the kernel is what runs the + // query, so its version is the one worth showing. It is the default, so + // there is nothing to qualify it against. + const metadata = generateMetadata(context(), { + kernelPath: '/ext/ggsql.ggsql-0.5.0/bundled/bin/ggsql-jupyter', + source: 'Bundled', + version: '0.5.0', + }); + assert.strictEqual(metadata.runtimeName, 'ggsql 0.5.0'); + assert.strictEqual(metadata.runtimeVersion, '0.5.0'); + assert.strictEqual(metadata.languageVersion, '0.5.0'); + // The console tab is per session, where the version adds nothing. + assert.strictEqual(metadata.runtimeShortName, 'ggsql'); + }); + + test('other runtimes keep a per-path id and a qualified name', () => { + const system = generateMetadata(context(), { + kernelPath: '/usr/local/bin/ggsql-jupyter', + source: 'System', + version: '0.4.0', + }); + const setting = generateMetadata(context(), { + kernelPath: '/opt/ggsql/ggsql-jupyter', + source: 'Setting', + version: '0.4.0', + }); + assert.strictEqual(system.runtimeName, 'ggsql 0.4.0 (System)'); + assert.strictEqual(setting.runtimeName, 'ggsql 0.4.0 (Setting)'); + assert.notStrictEqual(system.runtimeId, setting.runtimeId); + assert.notStrictEqual(system.runtimeId, 'ggsql-bundled'); + }); + + test('a kernel that reports no version falls back to the extension version', () => { + const metadata = generateMetadata(context(), { + kernelPath: '/usr/local/bin/ggsql-jupyter', + source: 'System', + }); + // Nothing to interpolate, so the name says only where it came from. + assert.strictEqual(metadata.runtimeName, 'ggsql (System)'); + assert.strictEqual(metadata.runtimeVersion, realExtension().packageJSON.version); + }); +}); + +suite('metadata validation', () => { + function managerFor(extensionPath: string, probe?: KernelProbe): GgsqlRuntimeManager { + return new GgsqlRuntimeManager(contextFor(extensionPath), { + kernelSpecDir: tempDir(), + probe: probe ?? (async () => ({ version: STUB_VERSION })), + }); + } + + test('bundled metadata from a superseded extension version is repointed', async () => { + // The fixed bundled runtime id is what carries runtime affinity and + // restorable sessions across an update; the path it was stored with + // points into the extension directory that update removed. + const superseded = extensionDirWithBundle(); + const current = extensionDirWithBundle(); + const stale = generateMetadata(contextFor(superseded.extensionPath), { + kernelPath: superseded.kernelPath, + source: 'Bundled', + version: '0.1.0', + }); + + const validated = await managerFor(current.extensionPath).validateMetadata(stale); + assert.strictEqual(validated.runtimeId, stale.runtimeId); + assert.strictEqual(validated.runtimePath, current.kernelPath); + assert.strictEqual(validated.runtimeVersion, STUB_VERSION); + }); + + test('metadata for a kernel that is no longer there is rejected', async () => { + // Rejecting is how Positron learns to drop a runtime it stored for a + // kernel that has since been uninstalled. + const { extensionPath } = extensionDirWithBundle(); + const gone = generateMetadata(contextFor(extensionPath), { + kernelPath: path.join(tempDir(), binaryName), + source: 'System', + version: '0.1.0', + }); + await assert.rejects( + () => managerFor(extensionPath).validateMetadata(gone), + /No usable ggsql kernel/, + ); + }); + + test('bundled metadata is rejected when the bundled kernel cannot run', async () => { + const { extensionPath, kernelPath } = extensionDirWithBundle(); + const metadata = generateMetadata(contextFor(extensionPath), { + kernelPath, + source: 'Bundled', + version: '0.1.0', + }); + await assert.rejects( + () => managerFor(extensionPath, async () => undefined).validateMetadata(metadata), + /No usable ggsql kernel/, + ); + }); +}); + +suite('kernel probe', () => { + // The probe reads the version to show in the picker, and is what separates a + // kernel that is present from one that runs. The failure it exists for is a + // binary built against newer shared libraries than the host provides: exec + // succeeds, the dynamic linker then rejects it, and the process exits + // non-zero. + + test('the reported version is read from the output', async function () { + if (process.platform === 'win32') { + this.skip(); + } + const kernelPath = writeStubKernel( + tempDir(), + 0o755, + '#!/bin/sh\necho "ggsql-jupyter 1.2.3"\n', + ); + assert.deepStrictEqual(await probeKernel(kernelPath), { version: '1.2.3' }); + }); + + test('a binary that exits zero without a version still passes', async function () { + // Its runtime is offered without a version rather than dropped. + if (process.platform === 'win32') { + this.skip(); + } + assert.deepStrictEqual(await probeKernel(writeStubKernel(tempDir())), { version: undefined }); + }); + + test('a binary that exits non-zero does not pass', async function () { + if (process.platform === 'win32') { + this.skip(); + } + const dir = tempDir(); + const kernelPath = path.join(dir, binaryName); + fs.writeFileSync(kernelPath, '#!/bin/sh\nexit 1\n'); + fs.chmodSync(kernelPath, 0o755); + assert.strictEqual(await probeKernel(kernelPath), undefined); + }); + + test('a kernel older than --version passes, without one', async function () { + // Kernels released before the flag reject it the way clap does, exiting + // non-zero. That is indistinguishable by exit status from a binary the + // loader killed, so the probe falls back to `--help`, which every + // version answers. Dropping these would take away the install the user + // already had. + if (process.platform === 'win32') { + this.skip(); + } + const kernelPath = writeStubKernel( + tempDir(), + 0o755, + '#!/bin/sh\ncase "$1" in --help) exit 0 ;; *) echo "error: unexpected argument" >&2; exit 2 ;; esac\n', + ); + assert.deepStrictEqual(await probeKernel(kernelPath), { version: undefined }); + }); + + test('a binary that rejects every argument does not pass', async function () { + // The `--help` fallback must not become a way back in for a kernel that + // exec's and then dies, which is the failure the probe exists for. + if (process.platform === 'win32') { + this.skip(); + } + const kernelPath = writeStubKernel(tempDir(), 0o755, '#!/bin/sh\nexit 127\n'); + assert.strictEqual(await probeKernel(kernelPath), undefined); + }); + + test('a file that is not executable at all does not pass', async () => { + // The nearest reachable stand-in for a binary the loader rejects: the + // spawn fails rather than the process exiting non-zero, and the probe + // has to treat both the same way. + const kernelPath = path.join(tempDir(), binaryName); + fs.writeFileSync(kernelPath, 'not a real executable\n'); + fs.chmodSync(kernelPath, 0o644); + assert.strictEqual(await probeKernel(kernelPath), undefined); + }); + + test('a missing binary does not pass', async () => { + assert.strictEqual(await probeKernel(path.join(tempDir(), binaryName)), undefined); + }); +}); + +suiteTeardown(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/ggsql-vscode/src/test/runIntegration.ts b/ggsql-vscode/src/test/runIntegration.ts new file mode 100644 index 000000000..80fdd8b73 --- /dev/null +++ b/ggsql-vscode/src/test/runIntegration.ts @@ -0,0 +1,50 @@ +/* + * Downloads Positron and runs the integration suite against it. + * + * Invoked by `npm run test:integration`. The stock VS Code suites go through + * @vscode/test-cli instead; only this suite needs a real Positron, because only + * it touches the language runtime API. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { runTests } from '@posit-dev/positron-test-electron'; + +async function main(): Promise { + // out-test/test/runIntegration.js -> the extension root + const extensionDevelopmentPath = path.resolve(__dirname, '..', '..'); + const extensionTestsPath = path.resolve(__dirname, 'integration', 'index'); + + const binaryName = process.platform === 'win32' ? 'ggsql-jupyter.exe' : 'ggsql-jupyter'; + const bundledKernel = path.join(extensionDevelopmentPath, 'bundled', 'bin', binaryName); + if (!fs.existsSync(bundledKernel)) { + // Failing here names the missing fixture, rather than letting the suite + // fail later on an absent runtime. + throw new Error( + `no bundled kernel at ${bundledKernel}\n` + + 'Build one first:\n' + + ' cargo build --release --bin ggsql-jupyter\n' + + ` mkdir -p ${path.dirname(bundledKernel)} && cp target/release/${binaryName} ${bundledKernel}`, + ); + } + + const code = await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + // Positron's stable channel is not published for every platform, and the + // daily build is what the extension is developed against. + channel: 'daily', + // The runtime needs positron.positron-supervisor, one of Positron's + // bundled extensions, to start a session at all. With the default + // --disable-extensions there would be no supervisor and every session + // start would fail. + disableExtensions: false, + }); + + process.exit(code); +} + +main().catch(err => { + console.error(err); + process.exit(1); +});