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 f170820d5..bd01cdc86 100644 --- a/justfile +++ b/justfile @@ -233,6 +233,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"], + }, +});