From 6cf3d0823df4cade3d8ec6048bcf3ac4cd694a02 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:00:39 -0400 Subject: [PATCH] Add extension contract tests and mock conformance checks Contract tests that validate which VSCode and Positron APIs the extension calls, with what arguments, and what it expects back. Tests mock the vscode and positron modules, import actual extension code against those mocks, and assert API interactions. Includes compile-time conformance checks that verify mock properties exist in the real @types/vscode and positron type definitions. Co-Authored-By: Claude Opus 4.6 --- .../workflows/extension-contract-tests.yaml | 20 + .github/workflows/main.yaml | 4 + .github/workflows/nightly.yaml | 24 +- .github/workflows/pull-request.yaml | 5 + justfile | 16 + test/extension-contract-tests/README.md | 84 + .../package-lock.json | 1606 +++++++++++++++++ test/extension-contract-tests/package.json | 15 + .../src/contracts/activation.test.ts | 188 ++ .../src/contracts/auth-provider.test.ts | 71 + .../src/contracts/connect-filesystem.test.ts | 113 ++ .../src/contracts/dialogs.test.ts | 87 + .../src/contracts/document-tracker.test.ts | 141 ++ .../src/contracts/extension-settings.test.ts | 66 + .../src/contracts/file-watchers.test.ts | 62 + .../contracts/interpreter-discovery.test.ts | 176 ++ .../src/contracts/llm-tools.test.ts | 51 + .../src/contracts/open-connect.test.ts | 156 ++ .../src/contracts/positron-settings.test.ts | 45 + .../src/contracts/window-utils.test.ts | 109 ++ .../src/helpers/extension-mocks.ts | 91 + .../src/mocks/positron.conformance.ts | 33 + .../src/mocks/positron.ts | 59 + .../src/mocks/vscode.conformance.ts | 117 ++ .../src/mocks/vscode.ts | 545 ++++++ .../tsconfig.conformance.json | 20 + test/extension-contract-tests/tsconfig.json | 19 + .../extension-contract-tests/vitest.config.ts | 17 + 28 files changed, 3938 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/extension-contract-tests.yaml create mode 100644 test/extension-contract-tests/README.md create mode 100644 test/extension-contract-tests/package-lock.json create mode 100644 test/extension-contract-tests/package.json create mode 100644 test/extension-contract-tests/src/contracts/activation.test.ts create mode 100644 test/extension-contract-tests/src/contracts/auth-provider.test.ts create mode 100644 test/extension-contract-tests/src/contracts/connect-filesystem.test.ts create mode 100644 test/extension-contract-tests/src/contracts/dialogs.test.ts create mode 100644 test/extension-contract-tests/src/contracts/document-tracker.test.ts create mode 100644 test/extension-contract-tests/src/contracts/extension-settings.test.ts create mode 100644 test/extension-contract-tests/src/contracts/file-watchers.test.ts create mode 100644 test/extension-contract-tests/src/contracts/interpreter-discovery.test.ts create mode 100644 test/extension-contract-tests/src/contracts/llm-tools.test.ts create mode 100644 test/extension-contract-tests/src/contracts/open-connect.test.ts create mode 100644 test/extension-contract-tests/src/contracts/positron-settings.test.ts create mode 100644 test/extension-contract-tests/src/contracts/window-utils.test.ts create mode 100644 test/extension-contract-tests/src/helpers/extension-mocks.ts create mode 100644 test/extension-contract-tests/src/mocks/positron.conformance.ts create mode 100644 test/extension-contract-tests/src/mocks/positron.ts create mode 100644 test/extension-contract-tests/src/mocks/vscode.conformance.ts create mode 100644 test/extension-contract-tests/src/mocks/vscode.ts create mode 100644 test/extension-contract-tests/tsconfig.conformance.json create mode 100644 test/extension-contract-tests/tsconfig.json create mode 100644 test/extension-contract-tests/vitest.config.ts diff --git a/.github/workflows/extension-contract-tests.yaml b/.github/workflows/extension-contract-tests.yaml new file mode 100644 index 000000000..d6ce2e667 --- /dev/null +++ b/.github/workflows/extension-contract-tests.yaml @@ -0,0 +1,20 @@ +name: Extension-Contract-Tests +on: [workflow_call] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: test/extension-contract-tests + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "test/extension-contract-tests/package-lock.json" + - run: npm ci + - run: npm test + - run: npm run check:conformance diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 85882727c..2197520a8 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -13,6 +13,8 @@ jobs: uses: ./.github/workflows/agent.yaml vscode: uses: ./.github/workflows/vscode.yaml + extension-contract-tests: + uses: ./.github/workflows/extension-contract-tests.yaml connect-contract-tests: uses: ./.github/workflows/connect-contract-tests.yaml @@ -62,6 +64,7 @@ jobs: [ agent, vscode, + extension-contract-tests, connect-contract-tests, build, package, @@ -92,6 +95,7 @@ jobs: [ agent, vscode, + extension-contract-tests, connect-contract-tests, build, package, diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index aa3621e52..ac3e13e09 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -26,13 +26,24 @@ jobs: # Extensions vscode: uses: ./.github/workflows/vscode.yaml + extension-contract-tests: + uses: ./.github/workflows/extension-contract-tests.yaml connect-contract-tests: uses: ./.github/workflows/connect-contract-tests.yaml # Slack notification on failure slack-notification: - needs: [agent, build, package, e2e, vscode, connect-contract-tests] + needs: + [ + agent, + build, + package, + e2e, + vscode, + extension-contract-tests, + connect-contract-tests, + ] if: failure() runs-on: ubuntu-latest steps: @@ -51,7 +62,16 @@ jobs: # Slack notification when nightly tests recover from failure slack-notification-resolved: - needs: [agent, build, package, e2e, vscode, connect-contract-tests] + needs: + [ + agent, + build, + package, + e2e, + vscode, + extension-contract-tests, + connect-contract-tests, + ] if: success() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index f01e5ef8a..bf39e9ef4 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -89,6 +89,11 @@ jobs: if: needs.detect-changes.outputs.has-code == 'true' uses: ./.github/workflows/vscode.yaml + extension-contract-tests: + needs: detect-changes + if: needs.detect-changes.outputs.has-code == 'true' + uses: ./.github/workflows/extension-contract-tests.yaml + connect-contract-tests: needs: detect-changes if: needs.detect-changes.outputs.has-code == 'true' diff --git a/justfile b/justfile index 064293c9b..01fe0956f 100644 --- a/justfile +++ b/justfile @@ -234,6 +234,22 @@ test *args=("-short ./..."): go test {{ args }} -covermode set -coverprofile=cover.out +# Run extension contract tests (validates vscode/positron API usage) +test-extension-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/extension-contract-tests && npm test + +# Check that vscode/positron mocks conform to real API type definitions +check-extension-contract-conformance: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/extension-contract-tests && npm run check:conformance + # Build the Connect API contract test harness binary build-connect-harness: go build -o test/connect-api-contracts/harness/harness ./test/connect-api-contracts/harness/ diff --git a/test/extension-contract-tests/README.md b/test/extension-contract-tests/README.md new file mode 100644 index 000000000..8f3d1225d --- /dev/null +++ b/test/extension-contract-tests/README.md @@ -0,0 +1,84 @@ +# Extension Contract Tests + +Contract tests that validate which **VSCode and Positron APIs** the extension calls, with what arguments, and what it expects back. These tests mock the `vscode` and `positron` modules, import actual extension code against those mocks, and assert API interactions. + +## Architecture + +``` +Test code → Import real extension module → Mock vscode/positron APIs + (e.g., src/dialogs.ts) (vi.fn() spies record calls) +``` + +No Go binary, no HTTP server, no network. Tests run entirely in-process using Vitest with module aliasing to intercept `vscode` and `positron` imports. + +## What's tested + +Each test file captures the contract between extension code and the VSCode/Positron API surface: + +| Test File | Extension Source | APIs Validated | +| ----------------------- | --------------------------- | ------------------------------------------------------------------------------------ | +| `positron-settings` | `utils/positronSettings.ts` | `workspace.getConfiguration("positron.r")` | +| `extension-settings` | `extension.ts` | `workspace.getConfiguration("positPublisher")` | +| `dialogs` | `dialogs.ts` | `window.showInformationMessage` (modal), `l10n.t` | +| `window-utils` | `utils/window.ts` | `window.showErrorMessage`, `withProgress`, `createTerminal` | +| `interpreter-discovery` | `utils/vscode.ts` | `commands.executeCommand`, `workspace.getConfiguration`, Positron runtime | +| `file-watchers` | `watchers.ts` | `workspace.createFileSystemWatcher`, `RelativePattern` | +| `llm-tools` | `llm/index.ts` | `lm.registerTool` | +| `open-connect` | `open_connect.ts` | `window.showInputBox`, `workspace.updateWorkspaceFolders`, `commands.executeCommand` | +| `auth-provider` | `authProvider.ts` | `authentication.registerAuthenticationProvider` | +| `connect-filesystem` | `connect_content_fs.ts` | `workspace.registerFileSystemProvider`, `FileSystemError` | +| `document-tracker` | `entrypointTracker.ts` | Editor/document change events, `commands.executeCommand("setContext")` | +| `activation` | `extension.ts` | `activate()` wiring: trust, URI handler, commands, contexts | + +## Mock design + +### `src/mocks/vscode.ts` + +Comprehensive mock of the `vscode` module with `vi.fn()` spies for all APIs the extension uses. Includes constructors (`Disposable`, `EventEmitter`, `Uri`, `RelativePattern`, etc.), enums (`FileType`, `ProgressLocation`, etc.), and namespace objects (`commands`, `window`, `workspace`, `authentication`, `lm`, `l10n`). + +### `src/mocks/positron.ts` + +Mock of the `positron` module providing `acquirePositronApi()` and the `LanguageRuntimeMetadata` type. + +### Conformance checks + +`src/mocks/vscode.conformance.ts` and `src/mocks/positron.conformance.ts` are compile-time checks that verify every property in our mocks also exists in the real API types. If a mock includes a misspelled or removed API, `tsc` will produce a compile error. Run with `npm run check:conformance`. + +### `src/helpers/` + +Shared mock setup used by tests that import modules with many transitive dependencies (e.g., `extension.ts`). Imported via `import "../helpers/extension-mocks"`. + +## Running + +```bash +# Install dependencies (first time) +cd test/extension-contract-tests && npm install + +# Run tests +just test-extension-contracts + +# Or directly +cd test/extension-contract-tests && npm test + +# Watch mode +cd test/extension-contract-tests && npm run test:watch + +# Run conformance checks (verify mocks match real API types) +just check-extension-contract-conformance + +# Or directly +cd test/extension-contract-tests && npm run check:conformance +``` + +## Adding a new contract test + +1. Create a new file in `src/contracts/` following the naming convention: `.test.ts` +2. Import `vi` from `vitest` and the relevant `vscode` APIs from the mock +3. Mock any internal dependencies with `vi.mock("src/...")` +4. Import the extension module under test with `await import("src/...")` +5. Write tests that call extension functions and assert which VSCode APIs were invoked + +## Related test suites + +- Extension unit tests (`extensions/vscode/src/**/*.test.ts`) — Test internal logic +- E2E tests (`test/e2e/`) — Full integration with Docker diff --git a/test/extension-contract-tests/package-lock.json b/test/extension-contract-tests/package-lock.json new file mode 100644 index 000000000..c137383e4 --- /dev/null +++ b/test/extension-contract-tests/package-lock.json @@ -0,0 +1,1606 @@ +{ + "name": "extension-contract-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "extension-contract-tests", + "devDependencies": { + "@types/vscode": "^1.87.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/extension-contract-tests/package.json b/test/extension-contract-tests/package.json new file mode 100644 index 000000000..3bffef13d --- /dev/null +++ b/test/extension-contract-tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "extension-contract-tests", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "check:conformance": "tsc --noEmit -p tsconfig.conformance.json" + }, + "devDependencies": { + "@types/vscode": "^1.87.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/extension-contract-tests/src/contracts/activation.test.ts b/test/extension-contract-tests/src/contracts/activation.test.ts new file mode 100644 index 000000000..f1d571667 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/activation.test.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: extension.ts activate() → trust checks, URI handler, command registration, setContext + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { commands, workspace, window, ExtensionMode } from "vscode"; + +// Mock all transitive dependencies of src/extension.ts +import "../helpers/extension-mocks"; + +const { activate, deactivate } = await import("src/extension"); + +describe("activation contract", () => { + function createMockContext() { + return { + subscriptions: [] as any[], + extensionMode: ExtensionMode.Production, + extensionUri: { fsPath: "/ext", path: "/ext" }, + extensionPath: "/ext", + globalState: { + get: vi.fn(), + update: vi.fn(() => Promise.resolve()), + keys: vi.fn(() => []), + setKeysForSync: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(() => Promise.resolve()), + keys: vi.fn(() => []), + }, + globalStorageUri: { fsPath: "/storage" }, + storageUri: { fsPath: "/ws-storage" }, + logUri: { fsPath: "/logs" }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + } as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + workspace.isTrusted = true; + }); + + // Helper: activate() calls initializeExtension() which is async. + // We need to flush microtasks to let it complete. + async function activateAndFlush(context: any) { + activate(context); + await vi.waitFor(() => {}); + } + + describe("workspace trust", () => { + it("checks workspace.isTrusted before initialization", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + // When trusted, commands are registered (initializeExtension runs) + expect(commands.registerCommand).toHaveBeenCalled(); + }); + + it("defers initialization when workspace is untrusted", () => { + const context = createMockContext(); + workspace.isTrusted = false; + + activate(context); + + // onDidGrantWorkspaceTrust should be subscribed + expect(workspace.onDidGrantWorkspaceTrust).toHaveBeenCalledTimes(1); + // registerCommand should NOT have been called for internal commands + // (only registerConnectContentFileSystem and registerUriHandler run immediately) + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const internalCommands = registerCalls.filter((c) => c[0] !== undefined); + // No commands should be registered yet (they happen in initializeExtension) + expect(internalCommands).toHaveLength(0); + }); + }); + + describe("URI handler", () => { + it("registers a URI handler via window.registerUriHandler", () => { + const context = createMockContext(); + activate(context); + + expect(window.registerUriHandler).toHaveBeenCalledTimes(1); + expect(window.registerUriHandler).toHaveBeenCalledWith( + expect.objectContaining({ handleUri: expect.any(Function) }), + ); + }); + }); + + describe("connect-content filesystem", () => { + it("registers the connect-content file system on activation", async () => { + const { registerConnectContentFileSystem } = + await import("src/connect_content_fs"); + const context = createMockContext(); + activate(context); + + expect(registerConnectContentFileSystem).toHaveBeenCalledTimes(1); + }); + }); + + describe("command registration (when trusted)", () => { + it("registers commands for core extension operations", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + const registeredIds = vi + .mocked(commands.registerCommand) + .mock.calls.map((c) => c[0]); + + // Check for key commands that initializeExtension registers + expect(registeredIds).toContain("posit.publisher.logs.fileview"); + expect(registeredIds).toContain("posit.publisher.logs.copy"); + expect(registeredIds).toContain("posit.publisher.init-project"); + expect(registeredIds).toContain("posit.publisher.showOutputChannel"); + expect(registeredIds).toContain("posit.publisher.showPublishingLog"); + expect(registeredIds).toContain("posit.publisher.openConnectContent"); + expect(registeredIds).toContain( + "posit.publisher.homeView.copySystemInfo", + ); + expect(registeredIds).toContain("posit.publisher.deployWithEntrypoint"); + }); + }); + + describe("setContext calls", () => { + it("sets posit.publish.state context to 'initialized' after setup", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + const setContextCalls = vi + .mocked(commands.executeCommand) + .mock.calls.filter((c) => c[0] === "setContext"); + + const stateCalls = setContextCalls.filter( + (c) => c[1] === "posit.publish.state", + ); + // Should set to "uninitialized" first, then "initialized" + expect(stateCalls.some((c) => c[2] === "uninitialized")).toBe(true); + expect(stateCalls.some((c) => c[2] === "initialized")).toBe(true); + }); + + it("sets initialization.inProgress context", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + const setContextCalls = vi + .mocked(commands.executeCommand) + .mock.calls.filter((c) => c[0] === "setContext"); + + const initCalls = setContextCalls.filter( + (c) => c[1] === "posit.publish.initialization.inProgress", + ); + expect(initCalls.some((c) => c[2] === "false")).toBe(true); + }); + }); + + describe("workspace folder change listener", () => { + it("subscribes to workspace.onDidChangeWorkspaceFolders", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + expect(workspace.onDidChangeWorkspaceFolders).toHaveBeenCalledTimes(1); + }); + }); + + describe("subscriptions", () => { + it("pushes disposables to context.subscriptions", async () => { + const context = createMockContext(); + workspace.isTrusted = true; + + await activateAndFlush(context); + + expect(context.subscriptions.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/auth-provider.test.ts b/test/extension-contract-tests/src/contracts/auth-provider.test.ts new file mode 100644 index 000000000..b23ece60e --- /dev/null +++ b/test/extension-contract-tests/src/contracts/auth-provider.test.ts @@ -0,0 +1,71 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: authProvider.ts → authentication.registerAuthenticationProvider + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { authentication, Disposable, EventEmitter } from "vscode"; + +// Mock internal dependencies +vi.mock("src/api", () => ({ + useApi: vi.fn(() => + Promise.resolve({ + credentials: { + delete: vi.fn(() => Promise.resolve()), + }, + }), + ), + Credential: class {}, +})); + +vi.mock("src/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("src/utils/errors", () => ({ + getSummaryStringFromError: vi.fn((loc: string, err: any) => `${loc}: ${err}`), +})); + +const { PublisherAuthProvider } = await import("src/authProvider"); + +describe("auth-provider contract", () => { + function createMockState(credentials: any[] = []) { + const emitter = new EventEmitter(); + return { + credentials, + onDidRefreshCredentials: vi.fn((listener: any) => + emitter.event(listener), + ), + refreshCredentials: vi.fn(() => Promise.resolve()), + _fireRefresh: (e: any) => emitter.fire(e), + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers with authentication.registerAuthenticationProvider", () => { + const state = createMockState(); + new PublisherAuthProvider(state as any); + + expect(authentication.registerAuthenticationProvider).toHaveBeenCalledWith( + "posit-connect", + "Posit Connect", + expect.any(Object), + { supportsMultipleAccounts: true }, + ); + }); + + it("exposes onDidChangeSessions event", () => { + const state = createMockState(); + const provider = new PublisherAuthProvider(state as any); + + expect(provider.onDidChangeSessions).toBeDefined(); + expect(typeof provider.onDidChangeSessions).toBe("function"); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/connect-filesystem.test.ts b/test/extension-contract-tests/src/contracts/connect-filesystem.test.ts new file mode 100644 index 000000000..ff8dbaa38 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/connect-filesystem.test.ts @@ -0,0 +1,113 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: connect_content_fs.ts → workspace.registerFileSystemProvider, FileSystemError, FileType + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { workspace, FileSystemError, FileType, Uri } from "vscode"; + +// Mock internal dependencies +vi.mock("src/api", () => ({ + useApi: vi.fn(() => + Promise.resolve({ + openConnectContent: { + openConnectContent: vi.fn(() => + Promise.resolve({ data: new ArrayBuffer(0) }), + ), + }, + }), + ), +})); + +vi.mock("src/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("src/constants", () => ({ + Commands: { + HomeView: { + AddCredential: "posit.publisher.homeView.addCredential", + }, + }, +})); + +// Mock third-party dependencies that connect_content_fs.ts imports +vi.mock("tar-stream", () => ({ + default: { extract: vi.fn(() => ({ on: vi.fn() })) }, + extract: vi.fn(() => ({ on: vi.fn() })), +})); + +vi.mock("axios", () => ({ + default: { isAxiosError: vi.fn(() => false) }, + isAxiosError: vi.fn(() => false), +})); + +const { + registerConnectContentFileSystem, + ConnectContentFileSystemProvider, + connectContentUri, +} = await import("src/connect_content_fs"); + +describe("connect-filesystem contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("registerConnectContentFileSystem", () => { + it("calls workspace.registerFileSystemProvider with connect-content scheme", () => { + const mockStatePromise = Promise.resolve({} as any); + registerConnectContentFileSystem(mockStatePromise); + + expect(workspace.registerFileSystemProvider).toHaveBeenCalledWith( + "connect-content", + expect.any(Object), + { isReadonly: true }, + ); + }); + }); + + describe("ConnectContentFileSystemProvider", () => { + it("exposes onDidChangeFile event", () => { + const provider = new ConnectContentFileSystemProvider( + Promise.resolve({} as any), + ); + expect(provider.onDidChangeFile).toBeDefined(); + }); + + it("watch() returns a Disposable", () => { + const provider = new ConnectContentFileSystemProvider( + Promise.resolve({} as any), + ); + const disposable = provider.watch(Uri.file("/test") as any, {} as any); + expect(disposable).toHaveProperty("dispose"); + }); + + it.each(["createDirectory", "writeFile", "delete", "rename"])( + "%s throws FileSystemError.NoPermissions", + (method) => { + const provider = new ConnectContentFileSystemProvider( + Promise.resolve({} as any), + ); + expect(() => (provider as any)[method]()).toThrow(); + expect(FileSystemError.NoPermissions).toHaveBeenCalledWith( + "connect-content is read-only", + ); + }, + ); + }); + + describe("connectContentUri", () => { + it("creates a URI with connect-content scheme", () => { + const uri = connectContentUri("https://connect.example.com", "test-guid"); + expect(Uri.from).toHaveBeenCalledWith({ + scheme: "connect-content", + authority: expect.any(String), + path: "/test-guid", + }); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/dialogs.test.ts b/test/extension-contract-tests/src/contracts/dialogs.test.ts new file mode 100644 index 000000000..9734e60f2 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/dialogs.test.ts @@ -0,0 +1,87 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: dialogs.ts → window.showInformationMessage (modal), l10n.t + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { window, l10n } from "vscode"; + +// Capture l10n.t call history at module load time (before clearAllMocks) +const l10nCallsAtLoad = [...((l10n.t as any).mock?.calls ?? [])]; + +// Import after vscode mock is in place +const dialogs = await import("src/dialogs"); + +// Record l10n.t calls that happened during module load +const l10nCallsAfterImport = [...((l10n.t as any).mock?.calls ?? [])]; + +describe("dialogs contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock showInformationMessage to return the affirmativeItem argument + // (the third positional arg). The confirm() function uses === reference + // equality, so we must return the exact same object reference. + vi.mocked(window.showInformationMessage).mockImplementation( + (...args: any[]) => { + // args: [message, options, item?] or [message, options] + // Return the last argument if it's an item (has 'title') + const lastArg = args[args.length - 1]; + if (lastArg && typeof lastArg === "object" && "title" in lastArg) { + return Promise.resolve(lastArg); + } + return Promise.resolve(undefined); + }, + ); + }); + + describe("l10n.t usage for button labels", () => { + it("uses l10n.t to localize button titles", () => { + // dialogs.ts calls l10n.t at module level for all items. + // Check the calls that were captured during module import. + const calledArgs = l10nCallsAfterImport.map((c) => c[0]); + expect(calledArgs).toContain("OK"); + expect(calledArgs).toContain("Delete"); + expect(calledArgs).toContain("Forget"); + expect(calledArgs).toContain("Overwrite"); + expect(calledArgs).toContain("Replace"); + expect(calledArgs).toContain("Yes"); + }); + }); + + it.each([ + ["confirmOK", "OK"], + ["confirmYes", "Yes"], + ["confirmDelete", "Delete"], + ["confirmForget", "Forget"], + ["confirmReplace", "Replace"], + ["confirmOverwrite", "Overwrite"], + ] as const)( + "%s calls showInformationMessage with modal option and %s item", + async (fnName, title) => { + const fn = dialogs[fnName] as (msg: string) => Promise; + const result = await fn("Test message"); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Test message", + { modal: true }, + { title }, + ); + expect(result).toBe(true); + }, + ); + + it("returns false when user cancels a confirm dialog", async () => { + vi.mocked(window.showInformationMessage).mockResolvedValue(undefined); + const result = await dialogs.confirmOK("Are you sure?"); + expect(result).toBe(false); + }); + + describe("alert", () => { + it("calls window.showInformationMessage with modal option only", async () => { + vi.mocked(window.showInformationMessage).mockResolvedValue(undefined); + await dialogs.alert("Something happened"); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Something happened", + { modal: true }, + ); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/document-tracker.test.ts b/test/extension-contract-tests/src/contracts/document-tracker.test.ts new file mode 100644 index 000000000..e665787b4 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/document-tracker.test.ts @@ -0,0 +1,141 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: entrypointTracker.ts → editor tracking events, commands.executeCommand("setContext") + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { window, workspace, commands, Disposable } from "vscode"; + +// Mock internal dependencies +vi.mock("src/api", () => ({ + useApi: vi.fn(() => + Promise.resolve({ + configurations: { + inspect: vi.fn(() => + Promise.resolve({ data: [{ configuration: { type: "unknown" } }] }), + ), + }, + }), + ), +})); + +vi.mock("src/utils/vscode", () => ({ + getPythonInterpreterPath: vi.fn(() => Promise.resolve(undefined)), + getRInterpreterPath: vi.fn(() => Promise.resolve(undefined)), +})); + +vi.mock("src/utils/files", () => ({ + isActiveDocument: vi.fn(() => false), + relativeDir: vi.fn(() => "."), +})); + +vi.mock("src/utils/inspect", () => ({ + hasKnownContentType: vi.fn(() => false), +})); + +vi.mock("src/utils/errors", () => ({ + getSummaryStringFromError: vi.fn(() => "error summary"), + isConnectionRefusedError: vi.fn(() => false), +})); + +vi.mock("vscode-uri", () => ({ + Utils: { basename: vi.fn((uri: any) => "file.py") }, +})); + +vi.mock("src/utils/getUri", () => ({ + getFileUriFromTab: vi.fn(() => undefined), +})); + +const { DocumentTracker } = await import("src/entrypointTracker"); + +describe("document-tracker contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.activeTextEditor = undefined; + window.activeNotebookEditor = undefined; + }); + + describe("event subscriptions", () => { + it("subscribes to window.onDidChangeActiveTextEditor", () => { + new DocumentTracker(); + expect(window.onDidChangeActiveTextEditor).toHaveBeenCalledTimes(1); + expect(window.onDidChangeActiveTextEditor).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to window.onDidChangeActiveNotebookEditor", () => { + new DocumentTracker(); + expect(window.onDidChangeActiveNotebookEditor).toHaveBeenCalledTimes(1); + expect(window.onDidChangeActiveNotebookEditor).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to workspace.onDidCloseTextDocument", () => { + new DocumentTracker(); + expect(workspace.onDidCloseTextDocument).toHaveBeenCalledTimes(1); + expect(workspace.onDidCloseTextDocument).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to workspace.onDidSaveTextDocument", () => { + new DocumentTracker(); + expect(workspace.onDidSaveTextDocument).toHaveBeenCalledTimes(1); + expect(workspace.onDidSaveTextDocument).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to workspace.onDidCloseNotebookDocument", () => { + new DocumentTracker(); + expect(workspace.onDidCloseNotebookDocument).toHaveBeenCalledTimes(1); + expect(workspace.onDidCloseNotebookDocument).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to workspace.onDidSaveNotebookDocument", () => { + new DocumentTracker(); + expect(workspace.onDidSaveNotebookDocument).toHaveBeenCalledTimes(1); + expect(workspace.onDidSaveNotebookDocument).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + + it("subscribes to window.tabGroups.onDidChangeTabGroups", () => { + new DocumentTracker(); + expect(window.tabGroups.onDidChangeTabGroups).toHaveBeenCalledTimes(1); + expect(window.tabGroups.onDidChangeTabGroups).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object), + ); + }); + }); + + describe("setContext calls", () => { + it("uses commands.executeCommand('setContext', 'posit.publish.activeFileEntrypoint', value)", async () => { + const tracker = new DocumentTracker(); + + // Simulate an editor change with a text editor + const mockDoc = { + uri: { fsPath: "/workspace/test.py", path: "/workspace/test.py" }, + }; + const mockEditor = { document: mockDoc }; + + await tracker.onActiveEditorChanged(mockEditor as any); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "setContext", + "posit.publish.activeFileEntrypoint", + expect.any(Boolean), + ); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/extension-settings.test.ts b/test/extension-contract-tests/src/contracts/extension-settings.test.ts new file mode 100644 index 000000000..24f8d8d8a --- /dev/null +++ b/test/extension-contract-tests/src/contracts/extension-settings.test.ts @@ -0,0 +1,66 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: extension.ts extensionSettings → workspace.getConfiguration("positPublisher") + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { workspace } from "vscode"; + +// Mock all transitive dependencies of src/extension.ts +import "../helpers/extension-mocks"; + +const { extensionSettings } = await import("src/extension"); + +describe("extension-settings contract", () => { + const mockGet = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockReturnValue(undefined); + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: mockGet, + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + } as any); + }); + + it("reads verifyCertificates from positPublisher config (default true)", () => { + mockGet.mockReturnValue(undefined); + const result = extensionSettings.verifyCertificates(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positPublisher"); + expect(mockGet).toHaveBeenCalledWith("verifyCertificates"); + expect(result).toBe(true); + }); + + it("reads useKeyChainCredentialStorage from positPublisher config (default true)", () => { + mockGet.mockReturnValue(undefined); + const result = extensionSettings.useKeyChainCredentialStorage(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positPublisher"); + expect(mockGet).toHaveBeenCalledWith("useKeyChainCredentialStorage"); + expect(result).toBe(true); + }); + + it("reads defaultConnectServer from positPublisher config (default '')", async () => { + mockGet.mockReturnValue(undefined); + const result = await extensionSettings.defaultConnectServer(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positPublisher"); + expect(mockGet).toHaveBeenCalledWith("defaultConnectServer"); + expect(result).toBe(""); + }); + + it("reads autoOpenLogsOnFailure from positPublisher config (default true)", () => { + mockGet.mockReturnValue(undefined); + const result = extensionSettings.autoOpenLogsOnFailure(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positPublisher"); + expect(mockGet).toHaveBeenCalledWith("autoOpenLogsOnFailure"); + expect(result).toBe(true); + }); + + it("reads enableConnectCloud from positPublisher config (default true)", () => { + mockGet.mockReturnValue(undefined); + const result = extensionSettings.enableConnectCloud(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positPublisher"); + expect(mockGet).toHaveBeenCalledWith("enableConnectCloud"); + expect(result).toBe(true); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/file-watchers.test.ts b/test/extension-contract-tests/src/contracts/file-watchers.test.ts new file mode 100644 index 000000000..3b0090015 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/file-watchers.test.ts @@ -0,0 +1,62 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: watchers.ts → workspace.createFileSystemWatcher, RelativePattern + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { workspace, RelativePattern, Uri } from "vscode"; + +// Mock internal dependencies +vi.mock("src/utils/files", () => ({ + relativePath: vi.fn((uri: any) => uri?.fsPath ?? uri?.path), +})); + +const { WatcherManager, ConfigWatcherManager } = await import("src/watchers"); + +describe("file-watchers contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("WatcherManager", () => { + it("uses RelativePattern with workspace.workspaceFolders[0] as the watcher root", () => { + new WatcherManager(); + const calls = vi.mocked(workspace.createFileSystemWatcher).mock.calls; + expect(calls.length).toBeGreaterThanOrEqual(1); + for (const call of calls) { + const pattern = call[0]; + expect(pattern).toBeInstanceOf(RelativePattern); + expect((pattern as RelativePattern).base).toBe( + workspace.workspaceFolders![0], + ); + } + }); + + it("does nothing when no workspace folders", () => { + const origFolders = workspace.workspaceFolders; + workspace.workspaceFolders = []; + new WatcherManager(); + expect(workspace.createFileSystemWatcher).not.toHaveBeenCalled(); + workspace.workspaceFolders = origFolders; + }); + }); + + describe("ConfigWatcherManager", () => { + it("uses Uri.joinPath to scope package file watchers to the project directory", () => { + const cfg = { + configurationPath: ".posit/publish/my-config.toml", + projectDir: "my-project", + configuration: { + python: { packageFile: "requirements.txt" }, + r: { packageFile: "renv.lock" }, + }, + }; + + new ConfigWatcherManager(cfg as any); + + expect(Uri.joinPath).toHaveBeenCalledWith( + workspace.workspaceFolders![0].uri, + "my-project", + ); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/interpreter-discovery.test.ts b/test/extension-contract-tests/src/contracts/interpreter-discovery.test.ts new file mode 100644 index 000000000..763a0eadc --- /dev/null +++ b/test/extension-contract-tests/src/contracts/interpreter-discovery.test.ts @@ -0,0 +1,176 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: utils/vscode.ts → Positron + VSCode runtime APIs for interpreter discovery + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { commands, workspace, Uri } from "vscode"; +import { + acquirePositronApi, + mockPositronRuntime, +} from "../../src/mocks/positron"; + +// Mock internal dependencies +vi.mock("src/utils/files", () => ({ + fileExists: vi.fn(() => Promise.resolve(false)), + isDir: vi.fn(() => Promise.resolve(false)), +})); + +vi.mock("src/utils/variables", () => ({ + substituteVariables: vi.fn((s: string) => s), +})); + +vi.mock("src/utils/throttle", () => ({ + delay: vi.fn(() => Promise.resolve()), +})); + +// Make acquirePositronApi available globally (as it is in the real extension) +(globalThis as any).acquirePositronApi = acquirePositronApi; + +const { fileExists, isDir } = await import("src/utils/files"); +const { + getPythonInterpreterPath, + getRInterpreterPath, + getPreferredRuntimeFromPositron, +} = await import("src/utils/vscode"); + +describe("interpreter-discovery contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset the Positron API cache by re-setting the global + (globalThis as any).acquirePositronApi = acquirePositronApi; + vi.mocked(fileExists).mockResolvedValue(false); + vi.mocked(isDir).mockResolvedValue(false); + }); + + describe("Positron runtime API", () => { + it("calls positron.runtime.getPreferredRuntime('python')", async () => { + mockPositronRuntime.getPreferredRuntime.mockResolvedValue({ + runtimePath: "/usr/bin/python3", + }); + const result = await getPreferredRuntimeFromPositron("python"); + expect(mockPositronRuntime.getPreferredRuntime).toHaveBeenCalledWith( + "python", + ); + expect(result).toBe("/usr/bin/python3"); + }); + + it("calls positron.runtime.getPreferredRuntime('r')", async () => { + mockPositronRuntime.getPreferredRuntime.mockResolvedValue({ + runtimePath: "/usr/bin/R", + }); + const result = await getPreferredRuntimeFromPositron("r"); + expect(mockPositronRuntime.getPreferredRuntime).toHaveBeenCalledWith("r"); + expect(result).toBe("/usr/bin/R"); + }); + + it("returns undefined when Positron API is not available", async () => { + (globalThis as any).acquirePositronApi = () => { + throw new Error("not in Positron"); + }; + // Need a fresh module to clear the positronApi cache + vi.resetModules(); + const mod = await import("src/utils/vscode"); + const result = await mod.getPreferredRuntimeFromPositron("python"); + expect(result).toBeUndefined(); + }); + }); + + describe("Python interpreter from VSCode", () => { + it("calls commands.executeCommand('python.interpreterPath', { workspaceFolder })", async () => { + mockPositronRuntime.getPreferredRuntime.mockRejectedValue( + new Error("not available"), + ); + vi.mocked(commands.executeCommand).mockResolvedValue( + "/usr/bin/python3" as any, + ); + + await getPythonInterpreterPath(); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "python.interpreterPath", + { workspaceFolder: workspace.workspaceFolders![0] }, + ); + }); + + it("returns undefined when no workspace folders exist", async () => { + mockPositronRuntime.getPreferredRuntime.mockRejectedValue( + new Error("not available"), + ); + const origFolders = workspace.workspaceFolders; + workspace.workspaceFolders = []; + + await getPythonInterpreterPath(); + + // Should not call executeCommand when no workspace + const pythonCalls = vi + .mocked(commands.executeCommand) + .mock.calls.filter((call) => call[0] === "python.interpreterPath"); + expect(pythonCalls).toHaveLength(0); + + workspace.workspaceFolders = origFolders; + }); + }); + + describe("R interpreter from VSCode config", () => { + it("reads workspace.getConfiguration('r.rpath').get(osType)", async () => { + // Make Positron unavailable + mockPositronRuntime.getPreferredRuntime.mockRejectedValue( + new Error("not available"), + ); + + const mockGet = vi.fn().mockReturnValue("/usr/local/bin/R"); + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: mockGet, + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + } as any); + vi.mocked(fileExists).mockResolvedValue(true); + + await getRInterpreterPath(); + + expect(workspace.getConfiguration).toHaveBeenCalledWith("r.rpath"); + // The OS type depends on process.platform + const expectedKey = + process.platform === "darwin" + ? "mac" + : process.platform === "win32" + ? "windows" + : "linux"; + expect(mockGet).toHaveBeenCalledWith(expectedKey); + }); + }); + + describe("Fallback order", () => { + it("prefers Positron over VSCode for Python", async () => { + mockPositronRuntime.getPreferredRuntime.mockResolvedValue({ + runtimePath: "/positron/python", + }); + vi.mocked(commands.executeCommand).mockResolvedValue( + "/vscode/python" as any, + ); + + const result = await getPythonInterpreterPath(); + + expect(result?.pythonPath).toBe("/positron/python"); + // Should not fall through to VSCode command + const pythonCalls = vi + .mocked(commands.executeCommand) + .mock.calls.filter((call) => call[0] === "python.interpreterPath"); + expect(pythonCalls).toHaveLength(0); + }); + + it("falls back to VSCode when Positron is unavailable for Python", async () => { + mockPositronRuntime.getPreferredRuntime.mockRejectedValue( + new Error("no runtime"), + ); + vi.mocked(commands.executeCommand).mockResolvedValue( + "/vscode/python3" as any, + ); + + const result = await getPythonInterpreterPath(); + + expect(result?.pythonPath).toBe("/vscode/python3"); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/llm-tools.test.ts b/test/extension-contract-tests/src/contracts/llm-tools.test.ts new file mode 100644 index 000000000..b7ccd6b8c --- /dev/null +++ b/test/extension-contract-tests/src/contracts/llm-tools.test.ts @@ -0,0 +1,51 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: llm/index.ts → lm.registerTool + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { lm } from "vscode"; + +// Mock internal dependencies +vi.mock("src/llm/tooling/troubleshoot/publishFailureTroubleshootTool", () => ({ + PublishFailureTroubleshootTool: vi.fn(() => ({ name: "publish-failure" })), +})); + +vi.mock("src/llm/tooling/troubleshoot/configurationTroubleshootTool", () => ({ + ConfigurationTroubleshootTool: vi.fn(() => ({ name: "config-error" })), +})); + +const { registerLLMTooling } = await import("src/llm/index"); + +describe("llm-tools contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers troubleshootDeploymentFailure tool", () => { + const mockContext = { + subscriptions: [] as any[], + }; + const mockState = {} as any; + + registerLLMTooling(mockContext as any, mockState); + + expect(lm.registerTool).toHaveBeenCalledWith( + "publish-content_troubleshootDeploymentFailure", + expect.any(Object), + ); + }); + + it("registers troubleshootConfigurationError tool", () => { + const mockContext = { + subscriptions: [] as any[], + }; + const mockState = {} as any; + + registerLLMTooling(mockContext as any, mockState); + + expect(lm.registerTool).toHaveBeenCalledWith( + "publish-content_troubleshootConfigurationError", + expect.any(Object), + ); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/open-connect.test.ts b/test/extension-contract-tests/src/contracts/open-connect.test.ts new file mode 100644 index 000000000..3d4a53ea8 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/open-connect.test.ts @@ -0,0 +1,156 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: open_connect.ts → window.showInputBox, workspace.updateWorkspaceFolders, commands.executeCommand + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { window, workspace, commands, Uri } from "vscode"; + +// Mock internal dependencies +vi.mock("src/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("src/connect_content_fs", () => ({ + clearConnectContentBundle: vi.fn(), + connectContentUri: vi.fn((_server: string, _guid: string) => ({ + scheme: "connect-content", + authority: "https@connect.example.com", + path: "/test-guid", + fsPath: "/test-guid", + query: "", + fragment: "", + toString: () => "connect-content://https@connect.example.com/test-guid", + })), + normalizeServerUrl: vi.fn((url: string) => { + try { + return new URL(url).origin; + } catch { + return ""; + } + }), +})); + +const { promptOpenConnectContent, handleConnectUri } = + await import("src/open_connect"); + +function makeConnectUri( + path = "/connect", + query = "server=https%3A%2F%2Fconnect.example.com&content=test-guid", +) { + return { + path, + query, + scheme: "vscode", + authority: "posit.publisher", + fsPath: path, + fragment: "", + with: vi.fn(), + toString: () => + `vscode://posit.publisher${path}${query ? "?" + query : ""}`, + }; +} + +describe("open-connect contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("promptOpenConnectContent", () => { + it("calls window.showInputBox for server URL with ignoreFocusOut", async () => { + vi.mocked(window.showInputBox).mockResolvedValueOnce(undefined); + + await promptOpenConnectContent(); + + expect(window.showInputBox).toHaveBeenCalledWith({ + prompt: "Connect server URL", + ignoreFocusOut: true, + }); + }); + + it("calls window.showInputBox for content GUID after server URL", async () => { + vi.mocked(window.showInputBox) + .mockResolvedValueOnce("https://connect.example.com") + .mockResolvedValueOnce(undefined); + + await promptOpenConnectContent(); + + expect(window.showInputBox).toHaveBeenCalledTimes(2); + expect(window.showInputBox).toHaveBeenNthCalledWith(2, { + prompt: "Connect content GUID", + ignoreFocusOut: true, + }); + }); + + it("exits early if user cancels server URL input", async () => { + vi.mocked(window.showInputBox).mockResolvedValueOnce(undefined); + + await promptOpenConnectContent(); + + expect(window.showInputBox).toHaveBeenCalledTimes(1); + }); + + it("exits early if user cancels content GUID input", async () => { + vi.mocked(window.showInputBox) + .mockResolvedValueOnce("https://connect.example.com") + .mockResolvedValueOnce(undefined); + + await promptOpenConnectContent(); + + expect(workspace.updateWorkspaceFolders).not.toHaveBeenCalled(); + expect(commands.executeCommand).not.toHaveBeenCalled(); + }); + }); + + describe("handleConnectUri", () => { + it("uses workspace.updateWorkspaceFolders when workspace folders exist", async () => { + vi.mocked(workspace.updateWorkspaceFolders).mockReturnValue(true); + + await handleConnectUri(makeConnectUri() as any); + + expect(workspace.updateWorkspaceFolders).toHaveBeenCalledWith( + 0, + expect.any(Number), + { uri: expect.any(Object) }, + ); + }); + + it("falls back to vscode.openFolder when no workspace folders", async () => { + const origFolders = workspace.workspaceFolders; + workspace.workspaceFolders = []; + + await handleConnectUri(makeConnectUri() as any); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "vscode.openFolder", + expect.any(Object), + { forceReuseWindow: true, forceNewWindow: false }, + ); + + workspace.workspaceFolders = origFolders; + }); + + it("falls back to vscode.openFolder when updateWorkspaceFolders fails", async () => { + vi.mocked(workspace.updateWorkspaceFolders).mockReturnValue(false); + + await handleConnectUri(makeConnectUri() as any); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "vscode.openFolder", + expect.any(Object), + { forceReuseWindow: true, forceNewWindow: false }, + ); + }); + + it("ignores URIs that are not /connect", async () => { + await handleConnectUri(makeConnectUri("/something-else", "") as any); + + expect(workspace.updateWorkspaceFolders).not.toHaveBeenCalled(); + expect(commands.executeCommand).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/positron-settings.test.ts b/test/extension-contract-tests/src/contracts/positron-settings.test.ts new file mode 100644 index 000000000..e0211cd84 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/positron-settings.test.ts @@ -0,0 +1,45 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: utils/positronSettings.ts → workspace.getConfiguration("positron.r") + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { workspace } from "vscode"; +import { getPositronRepoSettings } from "src/utils/positronSettings"; + +describe("positron-settings contract", () => { + const mockGet = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockReturnValue(undefined); + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: mockGet, + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + } as any); + }); + + it("reads positron.r configuration section", () => { + getPositronRepoSettings(); + expect(workspace.getConfiguration).toHaveBeenCalledWith("positron.r"); + }); + + it("reads defaultRepositories setting (defaults to 'auto')", () => { + mockGet.mockReturnValue(undefined); + const result = getPositronRepoSettings(); + expect(mockGet).toHaveBeenCalledWith("defaultRepositories"); + expect(result.r?.defaultRepositories).toBe("auto"); + }); + + it("reads packageManagerRepository setting", () => { + mockGet.mockImplementation((key: string) => { + if (key === "defaultRepositories") return "auto"; + if (key === "packageManagerRepository") return "https://ppm.example.com"; + return undefined; + }); + const result = getPositronRepoSettings(); + expect(mockGet).toHaveBeenCalledWith("packageManagerRepository"); + expect(result.r?.packageManagerRepository).toBe("https://ppm.example.com"); + }); +}); diff --git a/test/extension-contract-tests/src/contracts/window-utils.test.ts b/test/extension-contract-tests/src/contracts/window-utils.test.ts new file mode 100644 index 000000000..7fd15dd64 --- /dev/null +++ b/test/extension-contract-tests/src/contracts/window-utils.test.ts @@ -0,0 +1,109 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Contract: utils/window.ts → window.showErrorMessage, withProgress, createTerminal + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { window, ProgressLocation } from "vscode"; +import { + showErrorMessageWithTroubleshoot, + taskWithProgressMsg, + openTerminalCommand, + runTerminalCommand, +} from "src/utils/window"; + +function createMockTerminal() { + const terminal = { + sendText: vi.fn(), + show: vi.fn(), + exitStatus: { code: 0 }, + dispose: vi.fn(), + }; + vi.mocked(window.createTerminal).mockReturnValue(terminal as any); + return terminal; +} + +describe("window-utils contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("showErrorMessageWithTroubleshoot", () => { + it("calls window.showErrorMessage with troubleshooting link appended", () => { + showErrorMessageWithTroubleshoot("Something failed"); + expect(window.showErrorMessage).toHaveBeenCalledTimes(1); + const msg = vi.mocked(window.showErrorMessage).mock.calls[0][0]; + expect(msg).toContain("Something failed."); + expect(msg).toContain("Troubleshooting docs"); + expect(msg).toContain( + "github.com/posit-dev/publisher/blob/main/docs/troubleshooting.md", + ); + }); + + it("forwards extra items to showErrorMessage", () => { + showErrorMessageWithTroubleshoot("Fail", "Retry", "Cancel"); + expect(window.showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + "Retry", + "Cancel", + ); + }); + }); + + describe("taskWithProgressMsg", () => { + it("calls window.withProgress with Notification location", async () => { + const task = vi.fn(() => Promise.resolve()); + await taskWithProgressMsg("Loading...", task); + expect(window.withProgress).toHaveBeenCalledTimes(1); + const options = vi.mocked(window.withProgress).mock.calls[0][0]; + expect(options).toEqual({ + location: ProgressLocation.Notification, + title: "Loading...", + cancellable: false, + }); + }); + + it("sets cancellable to true when onCancel is provided", async () => { + const task = vi.fn(() => Promise.resolve()); + const onCancel = vi.fn(); + await taskWithProgressMsg("Loading...", task, onCancel); + const options = vi.mocked(window.withProgress).mock.calls[0][0]; + expect(options.cancellable).toBe(true); + }); + + it("invokes the task with progress and cancellation token", async () => { + const task = vi.fn(() => Promise.resolve()); + await taskWithProgressMsg("Work", task); + // The mock withProgress immediately invokes the task callback + expect(task).toHaveBeenCalledTimes(1); + const [progress, token] = task.mock.calls[0]; + expect(progress).toHaveProperty("report"); + expect(token).toHaveProperty("isCancellationRequested"); + expect(token).toHaveProperty("onCancellationRequested"); + }); + }); + + describe("openTerminalCommand", () => { + it("creates a terminal, sends command, and shows it", () => { + const terminal = createMockTerminal(); + openTerminalCommand("echo hello"); + expect(window.createTerminal).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith("echo hello;"); + expect(terminal.show).toHaveBeenCalledTimes(1); + }); + }); + + describe("runTerminalCommand", () => { + it("creates a terminal and sends command with exit", () => { + const terminal = createMockTerminal(); + runTerminalCommand("npm test"); + expect(window.createTerminal).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith("npm test; exit $?"); + }); + + it("listens to window.onDidCloseTerminal for terminal exit", () => { + createMockTerminal(); + runTerminalCommand("test"); + expect(window.onDidCloseTerminal).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/extension-contract-tests/src/helpers/extension-mocks.ts b/test/extension-contract-tests/src/helpers/extension-mocks.ts new file mode 100644 index 000000000..022db55b0 --- /dev/null +++ b/test/extension-contract-tests/src/helpers/extension-mocks.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Shared vi.mock() declarations for all dependencies of src/extension.ts. +// Import this file in any test that does `await import("src/extension")` to +// prevent transitive import failures. Vitest registers mock factories when +// this module is evaluated, before the dynamic import resolves. + +import { vi } from "vitest"; + +vi.mock("src/ports", () => ({ + acquire: vi.fn(() => Promise.resolve(9999)), +})); + +vi.mock("src/services", () => ({ + Service: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(() => Promise.resolve()), + showOutputChannel: vi.fn(), + })), +})); + +vi.mock("src/views/project", () => ({ + ProjectTreeDataProvider: vi.fn(() => ({ register: vi.fn() })), +})); + +vi.mock("src/views/logs", () => ({ + LogsTreeDataProvider: vi.fn(() => ({ register: vi.fn() })), + LogsViewProvider: Object.assign( + vi.fn(() => ({ register: vi.fn() })), + { openRawLogFileView: vi.fn(), copyLogs: vi.fn() }, + ), +})); + +vi.mock("src/events", () => ({ + EventStream: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("src/views/homeView", () => ({ + HomeViewProvider: vi.fn(() => ({ + register: vi.fn(), + showNewDeploymentMultiStep: vi.fn(() => Promise.resolve()), + handleFileInitiatedDeploymentSelection: vi.fn(), + dispose: vi.fn(), + })), +})); + +vi.mock("src/watchers", () => ({ + WatcherManager: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("src/entrypointTracker", () => ({ + DocumentTracker: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("src/utils/config", () => ({ + getXDGConfigProperty: vi.fn(() => Promise.resolve(null)), +})); + +vi.mock("src/state", () => ({ + PublisherState: vi.fn(() => ({ + credentials: [], + refreshCredentials: vi.fn(() => Promise.resolve()), + onDidRefreshCredentials: vi.fn(() => ({ dispose: vi.fn() })), + })), +})); + +vi.mock("src/authProvider", () => ({ + PublisherAuthProvider: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("src/logging", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("src/commands", () => ({ + copySystemInfoCommand: vi.fn(() => Promise.resolve()), +})); + +vi.mock("src/llm", () => ({ + registerLLMTooling: vi.fn(), +})); + +vi.mock("src/connect_content_fs", () => ({ + clearConnectContentBundleForUri: vi.fn(), + registerConnectContentFileSystem: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("src/open_connect", () => ({ + handleConnectUri: vi.fn(), + promptOpenConnectContent: vi.fn(() => Promise.resolve()), +})); diff --git a/test/extension-contract-tests/src/mocks/positron.conformance.ts b/test/extension-contract-tests/src/mocks/positron.conformance.ts new file mode 100644 index 000000000..aebc5811f --- /dev/null +++ b/test/extension-contract-tests/src/mocks/positron.conformance.ts @@ -0,0 +1,33 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Compile-time conformance check: validates that the positron mock's interfaces +// match the real Positron type declarations from extensions/vscode/src/@types/. +// +// Run: npm run check:conformance +// or: cd test/extension-contract-tests && npm run check:conformance +// +// The real Positron types come from the ambient module declaration at +// extensions/vscode/src/@types/positron.d.ts, which is included in +// tsconfig.conformance.json. Without the "positron" path alias, TypeScript +// resolves `import type from "positron"` to that ambient declaration. + +import type { + PositronApi as RealPositronApi, + LanguageRuntimeMetadata as RealMetadata, +} from "positron"; +import type { + PositronApi as MockPositronApi, + LanguageRuntimeMetadata as MockMetadata, +} from "./positron"; + +// --------------------------------------------------------------------------- +// Interface key checks +// --------------------------------------------------------------------------- +// Verify that every key in the mock interfaces exists in the real declarations. + +type _Api = Pick; +type _Metadata = Pick; + +// Note: acquirePositronApi() is exported by the mock but is not declared in the +// ambient positron.d.ts (it's a global function injected by the Positron runtime). +// It cannot be conformance-checked against types here. diff --git a/test/extension-contract-tests/src/mocks/positron.ts b/test/extension-contract-tests/src/mocks/positron.ts new file mode 100644 index 000000000..c59bab5c3 --- /dev/null +++ b/test/extension-contract-tests/src/mocks/positron.ts @@ -0,0 +1,59 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Mock of the "positron" module for contract tests. +// +// Positron is a fork of VS Code that provides additional APIs for data science +// workflows. The extension optionally depends on these APIs (they are only +// available when running inside Positron, not plain VS Code). +// +// This mock is aliased to the "positron" module via vitest.config.ts so that +// `import { acquirePositronApi } from "positron"` in extension source code +// resolves here instead of requiring the real Positron runtime. + +import { vi } from "vitest"; + +// Mirrors the Positron LanguageRuntimeMetadata interface. The extension reads +// these fields to discover which Python/R interpreters the user has configured +// in Positron (e.g., runtimePath for the interpreter binary, languageId to +// distinguish "python" vs "r", languageVersion for display). +export interface LanguageRuntimeMetadata { + runtimePath: string; + runtimeId: string; + runtimeName: string; + runtimeShortName: string; + runtimeVersion: string; + runtimeSource: string; + languageName: string; + languageId: string; + languageVersion: string; + base64EncodedIconSvg: string | undefined; + extraRuntimeData: any; +} + +// Shape of the object returned by acquirePositronApi(). The extension only uses +// the `runtime` namespace to query preferred interpreters. +export interface PositronApi { + version: string; + runtime: { + getPreferredRuntime(languageId: string): Promise; + }; +} + +// Spy for runtime.getPreferredRuntime(). Tests configure this via +// mockPositronRuntime.getPreferredRuntime.mockResolvedValue(...) to simulate +// Positron returning a specific Python or R runtime. +export const mockPositronRuntime = { + getPreferredRuntime: vi.fn(), +}; + +// The mock Positron API object. Returned by acquirePositronApi() below. +export const mockPositronApi: PositronApi = { + version: "1.0.0", + runtime: mockPositronRuntime, +}; + +// The global entry point that extension code calls to obtain the Positron API. +// In real Positron, this is a global function injected by the runtime. Here it +// returns our mockPositronApi so tests can verify the extension calls +// getPreferredRuntime("python") / getPreferredRuntime("r") correctly. +export const acquirePositronApi = vi.fn(() => mockPositronApi); diff --git a/test/extension-contract-tests/src/mocks/vscode.conformance.ts b/test/extension-contract-tests/src/mocks/vscode.conformance.ts new file mode 100644 index 000000000..67428f854 --- /dev/null +++ b/test/extension-contract-tests/src/mocks/vscode.conformance.ts @@ -0,0 +1,117 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Compile-time conformance check: validates that every property in our vscode +// mock also exists in the real @types/vscode API. If the mock includes a +// property that doesn't exist in the real API (a "phantom"), TypeScript will +// produce a compile error on the corresponding Pick<> line. +// +// Run: npm run check:conformance +// or: cd test/extension-contract-tests && npm run check:conformance +// +// This uses a separate tsconfig (tsconfig.conformance.json) that does NOT alias +// "vscode" to our mock, so `import type * as vscode from "vscode"` resolves to +// the real @types/vscode definitions while `import * as mock from "./vscode"` +// resolves to our mock file. +// +// How it works: +// Pick +// If MockType has a key "foo" that doesn't exist in RealType, TypeScript errors: +// Type '"foo"' does not satisfy the constraint 'keyof RealType'. +// +// What this catches: +// - Misspelled method/property names in the mock +// - Methods removed from the real API in newer @types/vscode versions +// - Accidentally invented APIs that don't exist in VS Code +// +// What this does NOT check: +// - Function signature compatibility (vi.fn() mocks have different types) +// - Mock completeness (the mock only covers APIs the extension uses) +// - Enum numeric values (verified visually against VS Code docs) + +import type * as vscode from "vscode"; +import type * as mock from "./vscode"; + +// --------------------------------------------------------------------------- +// Namespace key checks +// --------------------------------------------------------------------------- +// Each line verifies that every key in the mock namespace object also exists +// in the corresponding real vscode namespace. + +type _Commands = Pick; +type _Window = Pick; +type _Workspace = Pick; +type _Auth = Pick< + typeof vscode.authentication, + keyof typeof mock.authentication +>; +type _Env = Pick; +type _Lm = Pick; +type _L10n = Pick; + +// --------------------------------------------------------------------------- +// Nested object key checks +// --------------------------------------------------------------------------- +// For mock objects with nested structure, verify the inner keys too. + +type _WorkspaceFs = Pick< + typeof vscode.workspace.fs, + keyof typeof mock.workspace.fs +>; +type _EnvClipboard = Pick< + typeof vscode.env.clipboard, + keyof typeof mock.env.clipboard +>; +type _TabGroups = Pick< + typeof vscode.window.tabGroups, + keyof typeof mock.window.tabGroups +>; + +// --------------------------------------------------------------------------- +// Static method / factory key checks +// --------------------------------------------------------------------------- +// Uri and FileSystemError are classes in the real API with static methods. +// Our mock replicates the static surface as plain objects. + +type _Uri = Pick; +type _FsError = Pick< + typeof vscode.FileSystemError, + keyof typeof mock.FileSystemError +>; + +// --------------------------------------------------------------------------- +// Enum member checks +// --------------------------------------------------------------------------- +// Verify that every member in our mock enums exists in the real enums. + +type _TreeItemState = Pick< + typeof vscode.TreeItemCollapsibleState, + keyof typeof mock.TreeItemCollapsibleState +>; +type _FileType = Pick; +type _ProgressLoc = Pick< + typeof vscode.ProgressLocation, + keyof typeof mock.ProgressLocation +>; +type _ExtMode = Pick< + typeof vscode.ExtensionMode, + keyof typeof mock.ExtensionMode +>; +type _QuickInputBtns = Pick< + typeof vscode.QuickInputButtons, + keyof typeof mock.QuickInputButtons +>; + +// --------------------------------------------------------------------------- +// Class existence checks +// --------------------------------------------------------------------------- +// Verify the classes we mock exist in the real API. These are type-only +// references — if VS Code removed a class, the corresponding line would error. + +type _DisposableClass = typeof vscode.Disposable; +type _EventEmitterClass = typeof vscode.EventEmitter; +type _ThemeIconClass = typeof vscode.ThemeIcon; +type _ThemeColorClass = typeof vscode.ThemeColor; +type _RelativePatternClass = typeof vscode.RelativePattern; +type _TreeItemClass = typeof vscode.TreeItem; +type _RangeClass = typeof vscode.Range; +type _PositionClass = typeof vscode.Position; diff --git a/test/extension-contract-tests/src/mocks/vscode.ts b/test/extension-contract-tests/src/mocks/vscode.ts new file mode 100644 index 000000000..401653353 --- /dev/null +++ b/test/extension-contract-tests/src/mocks/vscode.ts @@ -0,0 +1,545 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// Comprehensive mock of the "vscode" module for contract tests. +// +// This file is aliased to the "vscode" module via vitest.config.ts, so any +// `import { ... } from "vscode"` in extension source code resolves here. +// All exported names mirror the real VS Code API surface so that extension +// source files can be imported without modification. +// +// Key design decisions: +// - Functions are vi.fn() spies so tests can assert calls and configure returns. +// - Classes (Disposable, EventEmitter, etc.) have real implementations because +// extension code relies on their constructor/method behavior at runtime. +// - Internal EventEmitter instances are exposed via `_testEmitters` (at the +// bottom of this file) so tests can programmatically fire events like +// onDidChangeActiveTextEditor to simulate user/editor actions. + +import { vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- +// These must match the real vscode enum values because extension code compares +// against them (e.g., `if (mode === ExtensionMode.Production)`). The numeric +// values come from the VS Code API documentation. + +// Used by tree view providers to control expand/collapse state of tree items. +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2, +} + +// Used by the workspace.fs API and file system providers to indicate entry type. +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +// Controls where progress indicators appear in the VS Code UI. +// The extension uses Notification (toast) for deployment progress. +export enum ProgressLocation { + SourceControl = 1, + Window = 10, + Notification = 15, +} + +// Indicates how the extension was launched. The extension checks this to +// conditionally enable development-only features. +export enum ExtensionMode { + Production = 1, + Development = 2, + Test = 3, +} + +// Built-in quick input navigation buttons. +export enum QuickInputButtons { + Back = "Back", +} + +// --------------------------------------------------------------------------- +// Constructors / Classes +// --------------------------------------------------------------------------- +// These have real implementations (not just spies) because extension code +// instantiates them and depends on their runtime behavior — for example, +// registering a command returns a Disposable whose dispose() must actually +// clean up, and EventEmitter.fire() must actually invoke listeners. + +// Represents a resource cleanup handle. VS Code returns Disposables from most +// registration APIs (registerCommand, onDidChange*, etc.) so code can later +// unsubscribe. This implementation is functionally equivalent to the real one. +export class Disposable { + private _callOnDispose: () => void; + constructor(callOnDispose: () => void) { + this._callOnDispose = callOnDispose; + } + dispose() { + this._callOnDispose(); + } + static from(...disposables: { dispose: () => void }[]): Disposable { + return new Disposable(() => disposables.forEach((d) => d.dispose())); + } +} + +// A working event emitter that supports subscribe/fire/dispose. Extension code +// creates its own EventEmitters for custom events and also subscribes to VS Code +// events. The `event` property is a function that registers a listener and returns +// a Disposable for unsubscription — matching the real VS Code API pattern. +export class EventEmitter { + private listeners: Array<(e: T) => void> = []; + event = (listener: (e: T) => void) => { + this.listeners.push(listener); + return new Disposable(() => { + this.listeners = this.listeners.filter((l) => l !== listener); + }); + }; + fire(data: T) { + for (const listener of this.listeners) { + listener(data); + } + } + dispose() { + this.listeners = []; + } +} + +// Mock of vscode.Uri — the extension's primary abstraction for file paths and +// URLs. Each factory method (file, joinPath, from, parse) returns an object with +// the same shape as a real Uri: fsPath, path, scheme, authority, query, fragment, +// with(), and toString(). Methods are spies so tests can assert construction args. +export const Uri = { + // Uri.file("/some/path") — creates a file:// URI from an absolute filesystem path. + file: vi.fn((path: string) => ({ + fsPath: path, + path, + scheme: "file", + authority: "", + query: "", + fragment: "", + with: vi.fn(function (this: any, change: Record) { + return { ...this, ...change }; + }), + toString: vi.fn(function (this: any) { + return `file://${this.path}`; + }), + })), + // Uri.joinPath(base, "subdir", "file.txt") — appends path segments to an existing Uri. + joinPath: vi.fn( + (base: { fsPath: string; path: string }, ...segments: string[]) => { + const joined = [base.fsPath, ...segments].join("/"); + return { + fsPath: joined, + path: joined, + scheme: "file", + authority: "", + query: "", + fragment: "", + with: vi.fn(function (this: any, change: Record) { + return { ...this, ...change }; + }), + toString: vi.fn(function (this: any) { + return `file://${this.path}`; + }), + }; + }, + ), + // Uri.from({ scheme, authority, path, ... }) — constructs a Uri from components. + from: vi.fn( + (components: { + scheme: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }) => ({ + scheme: components.scheme, + authority: components.authority ?? "", + path: components.path ?? "", + query: components.query ?? "", + fragment: components.fragment ?? "", + fsPath: components.path ?? "", + with: vi.fn(function (this: any, change: Record) { + return { ...this, ...change }; + }), + toString: vi.fn(function (this: any) { + return `${this.scheme}://${this.authority}${this.path}${this.query ? "?" + this.query : ""}`; + }), + }), + ), + // Uri.parse("https://example.com/path") — parses a string into a Uri. + // Uses the real URL constructor for accurate parsing behavior. + parse: vi.fn((value: string) => { + const url = new URL(value); + return { + scheme: url.protocol.replace(":", ""), + authority: url.host, + path: url.pathname, + query: url.search.replace("?", ""), + fragment: url.hash.replace("#", ""), + fsPath: url.pathname, + with: vi.fn(function (this: any, change: Record) { + return { ...this, ...change }; + }), + toString: () => value, + }; + }), +}; + +// Icon reference used in tree views, status bars, etc. +export class ThemeIcon { + constructor( + public readonly id: string, + public readonly color?: ThemeColor, + ) {} +} + +// Color reference from the current VS Code theme. +export class ThemeColor { + constructor(public readonly id: string) {} +} + +// Combines a workspace folder (or Uri) with a glob pattern for scoped file +// watching. The extension uses this with createFileSystemWatcher to watch only +// files within the project directory (e.g., `.posit/publish/**/*.toml`). +export class RelativePattern { + constructor( + public readonly base: any, + public readonly pattern: string, + ) {} +} + +// Base class for sidebar tree view items (deployment list, server list, etc.). +export class TreeItem { + label?: string; + collapsibleState?: TreeItemCollapsibleState; + constructor(label: string, collapsibleState?: TreeItemCollapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } +} + +// Text document range (start/end positions). Used for document edit operations. +export class Range { + constructor( + public readonly start: Position, + public readonly end: Position, + ) {} +} + +// Zero-based line and character position within a text document. +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + +// Error factories used by FileSystemProvider implementations. The extension's +// connect_content_fs.ts (Connect content browser) throws these when remote file +// operations fail. Each factory creates an Error with a `.code` property matching +// what VS Code expects for proper error handling in the file explorer UI. +export const FileSystemError = { + FileNotFound: vi.fn((uri?: any) => { + const err = new Error("FileNotFound"); + (err as any).code = "FileNotFound"; + (err as any).uri = uri; + return err; + }), + FileExists: vi.fn((uri?: any) => { + const err = new Error("FileExists"); + (err as any).code = "FileExists"; + (err as any).uri = uri; + return err; + }), + FileNotADirectory: vi.fn((uri?: any) => { + const err = new Error("FileNotADirectory"); + (err as any).code = "FileNotADirectory"; + (err as any).uri = uri; + return err; + }), + FileIsADirectory: vi.fn((uri?: any) => { + const err = new Error("FileIsADirectory"); + (err as any).code = "FileIsADirectory"; + (err as any).uri = uri; + return err; + }), + NoPermissions: vi.fn((msg?: string) => { + const err = new Error(msg ?? "NoPermissions"); + (err as any).code = "NoPermissions"; + return err; + }), +}; + +// --------------------------------------------------------------------------- +// Namespace: commands +// --------------------------------------------------------------------------- +// The extension registers ~20 commands (deploy, redeploy, open logs, etc.) via +// registerCommand and triggers built-in commands via executeCommand (e.g., +// "setContext" to toggle when-clause contexts, "vscode.open" to open URLs). + +export const commands = { + registerCommand: vi.fn( + (_id: string, _handler: (...args: any[]) => any) => + new Disposable(() => {}), + ), + executeCommand: vi.fn((..._args: any[]) => Promise.resolve()), +}; + +// --------------------------------------------------------------------------- +// Namespace: window +// --------------------------------------------------------------------------- +// Covers the extension's UI interactions: dialogs, progress toasts, terminals, +// tree views, URI handlers, and editor change tracking. + +// Internal emitters for window events. Tests fire these via _testEmitters +// (exported at the bottom) to simulate the user switching editors, closing +// terminals, etc. +const _onDidChangeActiveTextEditor = new EventEmitter(); +const _onDidChangeActiveNotebookEditor = new EventEmitter(); +const _onDidCloseTerminal = new EventEmitter(); + +export const window = { + // Dialog methods — default to resolving undefined (user dismissed the dialog). + // Tests override via mockReturnValue/mockResolvedValue to simulate button clicks. + showErrorMessage: vi.fn((..._args: any[]) => Promise.resolve(undefined)), + showInformationMessage: vi.fn((..._args: any[]) => + Promise.resolve(undefined), + ), + showWarningMessage: vi.fn((..._args: any[]) => Promise.resolve(undefined)), + // Text input — used by open_connect.ts to prompt for a Connect server URL. + showInputBox: vi.fn((..._args: any[]) => Promise.resolve(undefined)), + // Progress indicator — immediately invokes the task callback with a mock + // progress reporter and cancellation token, simulating synchronous completion. + withProgress: vi.fn( + (_options: any, task: (progress: any, token: any) => Promise) => + task( + { report: vi.fn() }, + { isCancellationRequested: false, onCancellationRequested: vi.fn() }, + ), + ), + // Terminal creation — the extension runs deployment commands in integrated terminals. + createTerminal: vi.fn(() => ({ + sendText: vi.fn(), + show: vi.fn(), + exitStatus: { code: 0 }, + dispose: vi.fn(), + })), + // Tree view — powers the sidebar panels (deployments list, credentials, etc.). + createTreeView: vi.fn(() => ({ + onDidChangeSelection: vi.fn(), + onDidExpandElement: vi.fn(), + onDidCollapseElement: vi.fn(), + onDidChangeVisibility: vi.fn(), + dispose: vi.fn(), + reveal: vi.fn(), + })), + showTextDocument: vi.fn((..._args: any[]) => Promise.resolve(undefined)), + // URI handler — enables posit-publisher:// deep links for OAuth callbacks. + registerUriHandler: vi.fn((_handler: any) => new Disposable(() => {})), + // Editor change events — the extension tracks which file is active to update + // the entrypoint context and refresh deployment UI accordingly. + onDidChangeActiveTextEditor: vi.fn((listener: any, thisArg?: any) => + _onDidChangeActiveTextEditor.event( + thisArg ? listener.bind(thisArg) : listener, + ), + ), + onDidChangeActiveNotebookEditor: vi.fn((listener: any, thisArg?: any) => + _onDidChangeActiveNotebookEditor.event( + thisArg ? listener.bind(thisArg) : listener, + ), + ), + onDidCloseTerminal: vi.fn((listener: any) => + _onDidCloseTerminal.event(listener), + ), + // Current editor state — tests can set these before importing extension code + // to simulate an already-open editor. + activeTextEditor: undefined as any, + activeNotebookEditor: undefined as any, + tabGroups: { + activeTabGroup: { activeTab: undefined as any }, + onDidChangeTabGroups: vi.fn( + (_listener: any, _thisArg?: any) => new Disposable(() => {}), + ), + }, +}; + +// --------------------------------------------------------------------------- +// Namespace: workspace +// --------------------------------------------------------------------------- +// Covers configuration reading, workspace folder management, file watching, +// filesystem provider registration, and document lifecycle events. + +// Mock for WorkspaceConfiguration.get() — returns undefined by default. +// Tests override via mockConfigGet.mockImplementation() to simulate specific +// settings (e.g., "positPublisher.executable" or "positron.r.interpreterPath"). +const mockConfigGet = vi.fn((_key?: string) => undefined); +// Mock for workspace.getConfiguration() — returns a configuration object scoped +// to the requested section (e.g., "positPublisher", "positron.r"). +const mockGetConfiguration = vi.fn((_section?: string) => ({ + get: mockConfigGet, + has: vi.fn(() => false), + inspect: vi.fn(() => undefined), + update: vi.fn(() => Promise.resolve()), +})); + +// Internal emitters for workspace events. Tests fire these via _testEmitters +// to simulate workspace trust changes, folder additions/removals, and +// document save/close events that trigger deployment config reloads. +const _onDidGrantWorkspaceTrust = new EventEmitter(); +const _onDidChangeWorkspaceFolders = new EventEmitter(); +const _onDidCloseTextDocument = new EventEmitter(); +const _onDidSaveTextDocument = new EventEmitter(); +const _onDidCloseNotebookDocument = new EventEmitter(); +const _onDidSaveNotebookDocument = new EventEmitter(); + +// Factory for mock FileSystemWatcher instances. The extension creates watchers +// for .posit/publish/ config files to auto-refresh when configs change on disk. +const mockFileSystemWatcher = () => ({ + onDidCreate: vi.fn(() => new Disposable(() => {})), + onDidChange: vi.fn(() => new Disposable(() => {})), + onDidDelete: vi.fn(() => new Disposable(() => {})), + dispose: vi.fn(), +}); + +export const workspace = { + getConfiguration: mockGetConfiguration, + // Pre-populated with a single workspace folder at "/workspace". Tests that + // need multi-root workspaces can push additional entries before importing + // extension code. + workspaceFolders: [ + { + uri: { fsPath: "/workspace", path: "/workspace", scheme: "file" }, + name: "workspace", + index: 0, + }, + ] as any[], + // Starts trusted. The extension gates activation on workspace trust — + // set to false in tests to verify the trust-gating behavior. + isTrusted: true, + onDidGrantWorkspaceTrust: vi.fn((listener: any) => + _onDidGrantWorkspaceTrust.event(listener), + ), + onDidChangeWorkspaceFolders: vi.fn((listener: any) => + _onDidChangeWorkspaceFolders.event(listener), + ), + // Returns true (success) by default. Used by open_connect.ts to add a + // Connect server's content as a virtual workspace folder. + updateWorkspaceFolders: vi.fn((..._args: any[]) => true), + // Creates a file watcher — the extension watches .posit/publish/*.toml and + // deployment record files for changes. + createFileSystemWatcher: vi.fn((..._args: any[]) => mockFileSystemWatcher()), + // Registers a virtual filesystem provider. The extension registers one for + // the "connect-content" scheme to browse deployed content on Connect. + registerFileSystemProvider: vi.fn( + (..._args: any[]) => new Disposable(() => {}), + ), + openTextDocument: vi.fn((..._args: any[]) => Promise.resolve({})), + // Document lifecycle events — the extension uses these to track which + // documents are open/saved to keep the entrypoint tracker in sync. + onDidCloseTextDocument: vi.fn((listener: any, thisArg?: any) => + _onDidCloseTextDocument.event(thisArg ? listener.bind(thisArg) : listener), + ), + onDidSaveTextDocument: vi.fn((listener: any, thisArg?: any) => + _onDidSaveTextDocument.event(thisArg ? listener.bind(thisArg) : listener), + ), + onDidCloseNotebookDocument: vi.fn((listener: any, thisArg?: any) => + _onDidCloseNotebookDocument.event( + thisArg ? listener.bind(thisArg) : listener, + ), + ), + onDidSaveNotebookDocument: vi.fn((listener: any, thisArg?: any) => + _onDidSaveNotebookDocument.event( + thisArg ? listener.bind(thisArg) : listener, + ), + ), + // Simplified workspace.fs — the virtual filesystem API. All methods resolve + // with empty/default values. Tests override individual methods as needed. + fs: { + stat: vi.fn(() => Promise.resolve({ type: FileType.File, size: 0 })), + readFile: vi.fn(() => Promise.resolve(new Uint8Array())), + writeFile: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + readDirectory: vi.fn(() => Promise.resolve([])), + createDirectory: vi.fn(() => Promise.resolve()), + rename: vi.fn(() => Promise.resolve()), + copy: vi.fn(() => Promise.resolve()), + }, +}; + +// --------------------------------------------------------------------------- +// Namespace: authentication +// --------------------------------------------------------------------------- +// The extension registers a custom AuthenticationProvider for Posit Connect +// API keys. This allows credentials to appear in VS Code's built-in Accounts +// menu and be shared with other extensions. + +export const authentication = { + // Registers the "posit-publisher" authentication provider. + registerAuthenticationProvider: vi.fn( + (..._args: any[]) => new Disposable(() => {}), + ), + // Retrieves an auth session — returns undefined (no session) by default. + getSession: vi.fn((..._args: any[]) => Promise.resolve(undefined)), +}; + +// --------------------------------------------------------------------------- +// Namespace: env +// --------------------------------------------------------------------------- +// Environment info and OS integration. The extension uses openExternal to +// launch Connect URLs in the browser and checks appName to detect whether +// it's running in VS Code vs Positron. + +export const env = { + openExternal: vi.fn((..._args: any[]) => Promise.resolve(true)), + clipboard: { writeText: vi.fn((..._args: any[]) => Promise.resolve()) }, + appName: "Visual Studio Code", +}; + +// --------------------------------------------------------------------------- +// Namespace: lm (Language Model) +// --------------------------------------------------------------------------- +// VS Code's language model API for AI/LLM tool integration. The extension +// registers tools (via lm.registerTool) that allow Copilot and other LLM +// agents to invoke Publisher actions like deploying content. + +export const lm = { + registerTool: vi.fn((..._args: any[]) => new Disposable(() => {})), +}; + +// --------------------------------------------------------------------------- +// Namespace: l10n (Localization) +// --------------------------------------------------------------------------- +// VS Code's localization API. The extension uses l10n.t() to mark strings for +// translation. This mock passes the message through unchanged, which is +// sufficient for contract tests that assert on message content. + +export const l10n = { + t: vi.fn((message: string, ..._args: any[]) => message), +}; + +// --------------------------------------------------------------------------- +// Event emitter accessors (used by tests to fire events on the mock) +// --------------------------------------------------------------------------- +// These expose the internal EventEmitter instances so that tests can +// programmatically fire VS Code events. For example: +// +// import { _testEmitters } from "vscode"; +// _testEmitters.onDidSaveTextDocument.fire(mockDocument); +// +// This simulates the user saving a file, which triggers the extension's +// document-save handler without needing a real VS Code editor. + +export const _testEmitters = { + onDidChangeActiveTextEditor: _onDidChangeActiveTextEditor, + onDidChangeActiveNotebookEditor: _onDidChangeActiveNotebookEditor, + onDidCloseTerminal: _onDidCloseTerminal, + onDidGrantWorkspaceTrust: _onDidGrantWorkspaceTrust, + onDidChangeWorkspaceFolders: _onDidChangeWorkspaceFolders, + onDidCloseTextDocument: _onDidCloseTextDocument, + onDidSaveTextDocument: _onDidSaveTextDocument, + onDidCloseNotebookDocument: _onDidCloseNotebookDocument, + onDidSaveNotebookDocument: _onDidSaveNotebookDocument, +}; diff --git a/test/extension-contract-tests/tsconfig.conformance.json b/test/extension-contract-tests/tsconfig.conformance.json new file mode 100644 index 000000000..8a24fbe00 --- /dev/null +++ b/test/extension-contract-tests/tsconfig.conformance.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2023", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023", "DOM"], + "paths": { + "src/*": ["../../extensions/vscode/src/*"] + } + }, + "include": [ + "src/mocks/*.conformance.ts", + "../../extensions/vscode/src/@types/positron.d.ts" + ] +} diff --git a/test/extension-contract-tests/tsconfig.json b/test/extension-contract-tests/tsconfig.json new file mode 100644 index 000000000..413e4e1a4 --- /dev/null +++ b/test/extension-contract-tests/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2023", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "lib": ["ES2023", "DOM"], + "paths": { + "vscode": ["./src/mocks/vscode.ts"], + "positron": ["./src/mocks/positron.ts"], + "src/*": ["../../extensions/vscode/src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/test/extension-contract-tests/vitest.config.ts b/test/extension-contract-tests/vitest.config.ts new file mode 100644 index 000000000..b5ccae46c --- /dev/null +++ b/test/extension-contract-tests/vitest.config.ts @@ -0,0 +1,17 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + vscode: path.resolve(__dirname, "src/mocks/vscode.ts"), + positron: path.resolve(__dirname, "src/mocks/positron.ts"), + src: path.resolve(__dirname, "../../extensions/vscode/src"), + }, + }, + test: { + include: ["src/contracts/**/*.test.ts"], + }, +});