diff --git a/justfile b/justfile index 0cf37c8a8..f4139868c 100644 --- a/justfile +++ b/justfile @@ -234,6 +234,30 @@ test *args=("-short ./..."): go test {{ args }} -covermode set -coverprofile=cover.out +# Run API contract tests against the Go binary (requires `just build` first) +test-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/api-contracts && npx vitest run + +# Run Connect API contract tests against the Go binary (requires `just build` first) +test-connect-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/connect-api-contracts && npx vitest run + +# Run extension API contract tests (no Go binary needed — uses mock server) +test-extension-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/extension-api-contracts && npx vitest run + # Execute Python script tests (licenses, prepare-release, etc.) test-scripts: #!/usr/bin/env bash diff --git a/test/api-contracts/README.md b/test/api-contracts/README.md new file mode 100644 index 000000000..ec6349f3f --- /dev/null +++ b/test/api-contracts/README.md @@ -0,0 +1,54 @@ +# Publisher API Contract Tests + +Contract tests that validate the HTTP API surface of the Publisher backend. These ensure that the Go backend and a future TypeScript backend produce identical responses for the same API calls. + +## Architecture + +``` +Test code → Publisher binary (Go) + GET/POST/PUT/PATCH/DELETE /api/* +``` + +A single server is involved: the Publisher binary, which is spawned as a subprocess. Tests call Publisher's own REST API and assert on response status codes and body shapes. + +## What's tested + +- **Configurations** — CRUD operations on `.posit/publish/*.toml` files +- **Credentials** — Create, list, delete server credentials +- **Deployments** — CRUD operations on `.posit/publish/deployments/*.toml` files + +## Client implementations + +| Client | Description | +|--------|-------------| +| `GoHttpClient` | Calls Publisher's HTTP API (the current Go binary) | +| `TypeScriptDirectClient` | Stub for future TS backend (all methods throw) | + +Set `API_BACKEND=typescript` to run against the TS client once implemented. + +## Running + +```bash +# Build the Go binary first +just build + +# Run contract tests +just test-contracts + +# Or directly +cd test/api-contracts && npx vitest run + +# Update snapshots +cd test/api-contracts && npx vitest run --update +``` + +## Adding tests + +1. Add a method to the `BackendClient` interface in `src/client.ts` +2. Implement it in both `src/clients/go-http-client.ts` and `src/clients/typescript-direct-client.ts` +3. Create a test file in `src/endpoints/` +4. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture workspace + +`src/fixtures/workspace/` contains a minimal project that Publisher needs to start. The workspace is copied to a temp directory for each test run. diff --git a/test/api-contracts/package-lock.json b/test/api-contracts/package-lock.json new file mode 100644 index 000000000..b288e3431 --- /dev/null +++ b/test/api-contracts/package-lock.json @@ -0,0 +1,1598 @@ +{ + "name": "api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-contracts", + "devDependencies": { + "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/@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/api-contracts/package.json b/test/api-contracts/package.json new file mode 100644 index 000000000..0213e115f --- /dev/null +++ b/test/api-contracts/package.json @@ -0,0 +1,14 @@ +{ + "name": "api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/api-contracts/src/client.ts b/test/api-contracts/src/client.ts new file mode 100644 index 000000000..e87dda358 --- /dev/null +++ b/test/api-contracts/src/client.ts @@ -0,0 +1,42 @@ +export type ResultStatus = + | "ok" + | "created" + | "no_content" + | "not_found" + | "conflict"; + +export interface ContractResult { + status: ResultStatus; + body: T; // parsed response body, or null for no_content +} + +export interface BackendClient { + // Configurations + getConfigurations(params?: { dir?: string }): Promise; + getConfiguration(name: string): Promise; + putConfiguration(name: string, body: unknown): Promise; + deleteConfiguration(name: string): Promise; + + // Credentials + getCredentials(): Promise; + postCredential(body: unknown): Promise; + deleteCredential(guid: string): Promise; + resetCredentials(): Promise; + + // Deployments + getDeployments(params?: { dir?: string }): Promise; + getDeployment(name: string): Promise; + postDeployment(body: unknown): Promise; + patchDeployment(name: string, body: unknown): Promise; + deleteDeployment(name: string): Promise; + + // Inspection + postInspect(params: { + dir?: string; + entrypoint?: string; + recursive?: string; + }): Promise; + + // Entrypoints + postEntrypoints(params?: { dir?: string }): Promise; +} diff --git a/test/api-contracts/src/clients/go-http-client.ts b/test/api-contracts/src/clients/go-http-client.ts new file mode 100644 index 000000000..7eb7155af --- /dev/null +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -0,0 +1,189 @@ +import type { BackendClient, ContractResult, ResultStatus } from "../client"; + +function mapStatus(httpStatus: number): ResultStatus { + switch (httpStatus) { + case 200: + return "ok"; + case 201: + return "created"; + case 204: + return "no_content"; + case 404: + return "not_found"; + case 409: + return "conflict"; + default: + throw new Error(`Unexpected HTTP status: ${httpStatus}`); + } +} + +async function toContractResult(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + let body: unknown = null; + + if (res.status !== 204 && contentType.includes("application/json")) { + body = await res.json(); + } + + return { status: mapStatus(res.status), body }; +} + +export class GoHttpClient implements BackendClient { + constructor(private apiBase: string) {} + + // Configurations + + async getConfigurations( + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations${query}`, + ); + return toContractResult(res); + } + + async getConfiguration(name: string): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${name}`, + ); + return toContractResult(res); + } + + async putConfiguration( + name: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${name}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + async deleteConfiguration(name: string): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${name}`, + { method: "DELETE" }, + ); + return toContractResult(res); + } + + // Credentials + + async getCredentials(): Promise { + const res = await fetch(`${this.apiBase}/api/credentials`); + return toContractResult(res); + } + + async postCredential(body: unknown): Promise { + const res = await fetch(`${this.apiBase}/api/credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return toContractResult(res); + } + + async deleteCredential(guid: string): Promise { + const res = await fetch( + `${this.apiBase}/api/credentials/${guid}`, + { method: "DELETE" }, + ); + return toContractResult(res); + } + + async resetCredentials(): Promise { + const res = await fetch(`${this.apiBase}/api/credentials`, { + method: "DELETE", + }); + return toContractResult(res); + } + + // Deployments + + async getDeployments( + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/deployments${query}`, + ); + return toContractResult(res); + } + + async getDeployment(name: string): Promise { + const res = await fetch( + `${this.apiBase}/api/deployments/${name}`, + ); + return toContractResult(res); + } + + async postDeployment(body: unknown): Promise { + const res = await fetch(`${this.apiBase}/api/deployments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return toContractResult(res); + } + + async patchDeployment( + name: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/deployments/${name}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + async deleteDeployment(name: string): Promise { + const res = await fetch( + `${this.apiBase}/api/deployments/${name}`, + { method: "DELETE" }, + ); + return toContractResult(res); + } + + // Inspection + + async postInspect(params: { + dir?: string; + entrypoint?: string; + recursive?: string; + }): Promise { + const qs = new URLSearchParams(); + if (params.dir) qs.set("dir", params.dir); + if (params.entrypoint) qs.set("entrypoint", params.entrypoint); + if (params.recursive) qs.set("recursive", params.recursive); + const query = qs.toString() ? `?${qs.toString()}` : ""; + const res = await fetch( + `${this.apiBase}/api/inspect${query}`, + { method: "POST" }, + ); + return toContractResult(res); + } + + // Entrypoints + + async postEntrypoints( + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/entrypoints${query}`, + { method: "POST" }, + ); + return toContractResult(res); + } +} diff --git a/test/api-contracts/src/clients/typescript-direct-client.ts b/test/api-contracts/src/clients/typescript-direct-client.ts new file mode 100644 index 000000000..9f22e1303 --- /dev/null +++ b/test/api-contracts/src/clients/typescript-direct-client.ts @@ -0,0 +1,96 @@ +import type { BackendClient, ContractResult } from "../client"; + +/** + * Stub client for the TypeScript backend implementation. + * Each method will be wired up as the corresponding TS service is built. + * For now, all methods throw "Not implemented yet". + */ +export class TypeScriptDirectClient implements BackendClient { + constructor(private workspaceDir: string) {} + + // Configurations + + async getConfigurations( + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getConfiguration(_name: string): Promise { + throw new Error("Not implemented yet"); + } + + async putConfiguration( + _name: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteConfiguration(_name: string): Promise { + throw new Error("Not implemented yet"); + } + + // Credentials + + async getCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + async postCredential(_body: unknown): Promise { + throw new Error("Not implemented yet"); + } + + async deleteCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + async resetCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + // Deployments + + async getDeployments( + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getDeployment(_name: string): Promise { + throw new Error("Not implemented yet"); + } + + async postDeployment(_body: unknown): Promise { + throw new Error("Not implemented yet"); + } + + async patchDeployment( + _name: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteDeployment(_name: string): Promise { + throw new Error("Not implemented yet"); + } + + // Inspection + + async postInspect(_params: { + dir?: string; + entrypoint?: string; + recursive?: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + // Entrypoints + + async postEntrypoints( + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap new file mode 100644 index 000000000..d7c6c0bad --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap @@ -0,0 +1,30 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GET /api/configurations > returns configurations array with pre-seeded config 1`] = ` +{ + "configuration": ObjectContaining { + "$schema": Any, + "entrypoint": "fastapi-simple/app.py", + "files": Any, + "type": "python-fastapi", + }, + "configurationName": Any, + "configurationPath": Any, + "configurationRelPath": Any, + "projectDir": Any, +} +`; + +exports[`GET /api/configurations/{name} > returns a single configuration by name 1`] = ` +{ + "configuration": ObjectContaining { + "$schema": Any, + "entrypoint": Any, + "type": Any, + }, + "configurationName": Any, + "configurationPath": Any, + "configurationRelPath": Any, + "projectDir": Any, +} +`; diff --git a/test/api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap new file mode 100644 index 000000000..299c22fd2 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`POST /api/credentials > creates a new Connect credential 1`] = ` +{ + "accessToken": "", + "accountId": "", + "accountName": "", + "apiKey": Any, + "cloudEnvironment": "", + "guid": Any, + "name": Any, + "privateKey": "", + "refreshToken": "", + "serverType": Any, + "snowflakeConnection": "", + "token": "", + "url": Any, +} +`; diff --git a/test/api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap new file mode 100644 index 000000000..1c72010c7 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GET /api/deployments > returns deployments array with pre-seeded deployment 1`] = ` +{ + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json", + "bundleId": Any, + "bundleUrl": "", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "fastapi-simple/app.py", + "files": [ + "fastapi-simple/app.py", + "fastapi-simple/requirements.txt", + ], + "productType": "connect", + "python": { + "packageFile": "fastapi-simple/requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + "validate": true, + }, + "configurationName": Any, + "configurationPath": Any, + "connectCloud": null, + "createdAt": Any, + "dashboardUrl": Any, + "deployedAt": Any, + "deploymentError": null, + "deploymentName": Any, + "deploymentPath": Any, + "directUrl": Any, + "dismissedAt": "", + "files": [ + "fastapi-simple/app.py", + "fastapi-simple/requirements.txt", + ], + "id": Any, + "logsUrl": Any, + "projectDir": Any, + "renv": null, + "requirements": null, + "saveName": Any, + "serverType": "connect", + "serverUrl": Any, + "state": "deployed", + "type": "python-fastapi", +} +`; diff --git a/test/api-contracts/src/endpoints/__snapshots__/entrypoints.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/entrypoints.test.ts.snap new file mode 100644 index 000000000..ccf977cee --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/entrypoints.test.ts.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`POST /api/entrypoints > returns entrypoints for a specific subdirectory 1`] = ` +[ + "app.py", +] +`; + +exports[`POST /api/entrypoints > returns entrypoints for the root workspace 1`] = ` +[ + "fastapi-simple/app.py", + "jupyter-nb/notebook.ipynb", + "quarto-doc/index.qmd", + "rmd-static/report.Rmd", + "shiny-python/app.py", + "static/index.html", +] +`; diff --git a/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap new file mode 100644 index 000000000..e86e9fb31 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap @@ -0,0 +1,458 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`POST /api/inspect (per-directory) > inspects fastapi-simple/ — detects python-fastapi 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "app.py", + "files": [ + "/app.py", + "/requirements.txt", + ], + "python": {}, + "title": "fastapi-simple", + "type": "python-fastapi", + "validate": true, + }, + "projectDir": "fastapi-simple", + }, +] +`; + +exports[`POST /api/inspect (per-directory) > inspects jupyter-nb/ — detects as quarto-static (Quarto inspects .ipynb) 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "notebook.html", + "files": [ + "/notebook.html", + ], + "source": "notebook.ipynb", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "notebook.ipynb", + "files": [ + "/notebook.ipynb", + "/requirements.txt", + ], + "python": {}, + "quarto": { + "engines": [ + "jupyter", + ], + "version": "1.5.57", + }, + "title": "jupyter-nb", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "jupyter-nb", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "notebook.ipynb", + "files": [ + "/notebook.ipynb", + "/requirements.txt", + ], + "python": {}, + "title": "jupyter-nb", + "type": "jupyter-notebook", + "validate": true, + }, + "projectDir": "jupyter-nb", + }, +] +`; + +exports[`POST /api/inspect (per-directory) > inspects quarto-doc/ — detects quarto content 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "index.html", + "files": [ + "/index.html", + ], + "source": "index.qmd", + "title": "Quarto Document", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "index.qmd", + "files": [ + "/index.qmd", + "/_quarto.yml", + ], + "quarto": { + "engines": [ + "markdown", + ], + "version": "1.5.57", + }, + "title": "Quarto Document", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "quarto-doc", + }, +] +`; + +exports[`POST /api/inspect (per-directory) > inspects rmd-static/ — detects as quarto-static (Quarto inspects .Rmd) 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "report.html", + "files": [ + "/report.html", + ], + "source": "report.Rmd", + "title": "Report", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "report.Rmd", + "files": [ + "/report.Rmd", + ], + "quarto": { + "engines": [ + "knitr", + ], + "version": "1.5.57", + }, + "r": {}, + "title": "Report", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "rmd-static", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "report.Rmd", + "files": [ + "/report.Rmd", + ], + "r": {}, + "title": "Report", + "type": "rmd", + "validate": true, + }, + "projectDir": "rmd-static", + }, +] +`; + +exports[`POST /api/inspect (per-directory) > inspects shiny-python/ — detects python-shiny 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "app.py", + "files": [ + "/app.py", + "/requirements.txt", + ], + "python": {}, + "title": "shiny-python", + "type": "python-shiny", + "validate": true, + }, + "projectDir": "shiny-python", + }, +] +`; + +exports[`POST /api/inspect (per-directory) > inspects static/ — detects html 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "index.html", + "files": [ + "/index.html", + ], + "title": "static", + "type": "html", + "validate": true, + }, + "projectDir": "static", + }, +] +`; + +exports[`POST /api/inspect (recursive) > inspects root workspace recursively — returns multiple content types 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "app.py", + "files": [ + "/app.py", + "/requirements.txt", + ], + "python": {}, + "title": "fastapi-simple", + "type": "python-fastapi", + "validate": true, + }, + "projectDir": "fastapi-simple", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "notebook.html", + "files": [ + "/notebook.html", + ], + "source": "notebook.ipynb", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "notebook.ipynb", + "files": [ + "/notebook.ipynb", + "/requirements.txt", + ], + "python": {}, + "quarto": { + "engines": [ + "jupyter", + ], + "version": "1.5.57", + }, + "title": "jupyter-nb", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "jupyter-nb", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "notebook.ipynb", + "files": [ + "/notebook.ipynb", + "/requirements.txt", + ], + "python": {}, + "title": "jupyter-nb", + "type": "jupyter-notebook", + "validate": true, + }, + "projectDir": "jupyter-nb", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "index.html", + "files": [ + "/index.html", + ], + "source": "index.qmd", + "title": "Quarto Document", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "index.qmd", + "files": [ + "/index.qmd", + "/_quarto.yml", + ], + "quarto": { + "engines": [ + "markdown", + ], + "version": "1.5.57", + }, + "title": "Quarto Document", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "quarto-doc", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "alternatives": [ + { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "report.html", + "files": [ + "/report.html", + ], + "source": "report.Rmd", + "title": "Report", + "type": "html", + "validate": true, + }, + ], + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "report.Rmd", + "files": [ + "/report.Rmd", + ], + "quarto": { + "engines": [ + "knitr", + ], + "version": "1.5.57", + }, + "r": {}, + "title": "Report", + "type": "quarto-static", + "validate": true, + }, + "projectDir": "rmd-static", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "report.Rmd", + "files": [ + "/report.Rmd", + ], + "r": {}, + "title": "Report", + "type": "rmd", + "validate": true, + }, + "projectDir": "rmd-static", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "app.py", + "files": [ + "/app.py", + "/requirements.txt", + ], + "python": {}, + "title": "shiny-python", + "type": "python-shiny", + "validate": true, + }, + "projectDir": "shiny-python", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "comments": [ + " Configuration file generated by Posit Publisher.", + " Please review and modify as needed. See the documentation for more options:", + " https://github.com/posit-dev/publisher/blob/main/docs/configuration.md", + ], + "entrypoint": "index.html", + "files": [ + "/index.html", + ], + "title": "static", + "type": "html", + "validate": true, + }, + "projectDir": "static", + }, +] +`; diff --git a/test/api-contracts/src/endpoints/configurations.test.ts b/test/api-contracts/src/endpoints/configurations.test.ts new file mode 100644 index 000000000..08dd21c80 --- /dev/null +++ b/test/api-contracts/src/endpoints/configurations.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient, seedConfigFile, removeConfigFile } from "../helpers"; + +const client = getClient(); + +describe("GET /api/configurations", () => { + it("returns configurations array with pre-seeded config", async () => { + const res = await client.getConfigurations(); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThan(0); + + // Find our pre-seeded config + const testConfig = body.find( + (c: any) => c.configurationName === "test-config", + ); + expect(testConfig).toBeDefined(); + expect(testConfig).toMatchSnapshot({ + configurationName: expect.any(String), + configurationPath: expect.any(String), + configurationRelPath: expect.any(String), + projectDir: expect.any(String), + configuration: expect.objectContaining({ + "$schema": expect.any(String), + type: "python-fastapi", + entrypoint: "fastapi-simple/app.py", + files: expect.any(Array), + }), + }); + }); + + it("returns empty array for directory with no configs", async () => { + const res = await client.getConfigurations({ dir: "static" }); + expect(res.status).toBe("ok"); + expect(res.body).toEqual([]); + }); +}); + +describe("GET /api/configurations/{name}", () => { + it("returns a single configuration by name", async () => { + const res = await client.getConfiguration("test-config"); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe("test-config"); + expect(body.configuration).toBeDefined(); + expect(body.configuration.type).toBe("python-fastapi"); + expect(body.configuration.entrypoint).toBe("fastapi-simple/app.py"); + expect(body).toMatchSnapshot({ + configurationName: expect.any(String), + configurationPath: expect.any(String), + configurationRelPath: expect.any(String), + projectDir: expect.any(String), + configuration: expect.objectContaining({ + "$schema": expect.any(String), + type: expect.any(String), + entrypoint: expect.any(String), + }), + }); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.getConfiguration("does-not-exist"); + expect(res.status).toBe("not_found"); + }); +}); + +describe("PUT /api/configurations/{name}", () => { + const testName = "put-test-config"; + + afterAll(async () => { + removeConfigFile(testName); + }); + + it("creates a new configuration", async () => { + const newConfig = { + productType: "connect", + type: "python-fastapi", + entrypoint: "fastapi-simple/app.py", + files: ["fastapi-simple/app.py", "fastapi-simple/requirements.txt"], + python: { + version: "3.11.3", + packageFile: "fastapi-simple/requirements.txt", + packageManager: "pip", + }, + }; + + const res = await client.putConfiguration(testName, newConfig); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + expect(body.configuration).toBeDefined(); + expect(body.configuration.type).toBe("python-fastapi"); + expect(body.configuration.entrypoint).toBe("fastapi-simple/app.py"); + }); + + it("can read back the created configuration", async () => { + const res = await client.getConfiguration(testName); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + expect(body.configuration.type).toBe("python-fastapi"); + }); +}); + +describe("DELETE /api/configurations/{name}", () => { + const testName = "delete-test-config"; + + it("deletes an existing configuration", async () => { + // First create a config to delete + seedConfigFile( + testName, + `"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-fastapi" +entrypoint = "fastapi-simple/app.py" +files = ["fastapi-simple/app.py"] + +[python] +version = "3.11.3" +package_manager = "pip" +`, + ); + + // Verify it exists + const getRes = await client.getConfiguration(testName); + expect(getRes.status).toBe("ok"); + + // Delete it + const deleteRes = await client.deleteConfiguration(testName); + expect(deleteRes.status).toBe("no_content"); + + // Verify it's gone + const afterRes = await client.getConfiguration(testName); + expect(afterRes.status).toBe("not_found"); + }); + + it("returns 404 when deleting non-existent configuration", async () => { + const res = await client.deleteConfiguration("does-not-exist"); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/endpoints/credentials.test.ts b/test/api-contracts/src/endpoints/credentials.test.ts new file mode 100644 index 000000000..0076d0bd0 --- /dev/null +++ b/test/api-contracts/src/endpoints/credentials.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("GET /api/credentials", () => { + it("returns credentials array (initially empty)", async () => { + const res = await client.getCredentials(); + expect(res.status).toBe("ok"); + + expect(res.body).toBeInstanceOf(Array); + }); +}); + +describe("POST /api/credentials", () => { + let createdGuid: string | null = null; + + it("creates a new Connect credential", async () => { + const newCred = { + name: "test-connect-server", + url: "https://connect.example.com", + serverType: "connect", + apiKey: "test-api-key-12345", + }; + + const res = await client.postCredential(newCred); + expect(res.status).toBe("created"); + + const body = res.body as any; + expect(body.guid).toBeDefined(); + expect(body.name).toBe("test-connect-server"); + expect(body.url).toBe("https://connect.example.com"); + expect(body.serverType).toBe("connect"); + expect(body.apiKey).toBe("test-api-key-12345"); + createdGuid = body.guid; + + expect(body).toMatchSnapshot({ + guid: expect.any(String), + name: expect.any(String), + url: expect.any(String), + serverType: expect.any(String), + apiKey: expect.any(String), + }); + }); + + it("credential appears in list after creation", async () => { + const res = await client.getCredentials(); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + const found = body.find((c: any) => c.guid === createdGuid); + expect(found).toBeDefined(); + expect(found.name).toBe("test-connect-server"); + }); + + it("returns 409 for duplicate URL", async () => { + const dupCred = { + name: "duplicate-server", + url: "https://connect.example.com", + serverType: "connect", + apiKey: "another-api-key", + }; + + const res = await client.postCredential(dupCred); + expect(res.status).toBe("conflict"); + }); +}); + +describe("DELETE /api/credentials/{guid}", () => { + it("deletes a credential by GUID", async () => { + // Create a credential to delete + const cred = { + name: "to-delete-server", + url: "https://delete-me.example.com", + serverType: "connect", + apiKey: "delete-me-key", + }; + const createRes = await client.postCredential(cred); + expect(createRes.status).toBe("created"); + const { guid } = createRes.body as any; + + // Delete it + const deleteRes = await client.deleteCredential(guid); + expect(deleteRes.status).toBe("no_content"); + + // Verify it's gone from the list + const listRes = await client.getCredentials(); + const list = listRes.body as any[]; + const found = list.find((c: any) => c.guid === guid); + expect(found).toBeUndefined(); + }); +}); + +describe("DELETE /api/credentials (reset)", () => { + it("resets all credentials", async () => { + // Ensure we have at least one credential + const listBefore = await client.getCredentials(); + const before = listBefore.body as any[]; + if (before.length === 0) { + await client.postCredential({ + name: "reset-test-server", + url: "https://reset-test.example.com", + serverType: "connect", + apiKey: "reset-test-key", + }); + } + + // Reset + const res = await client.resetCredentials(); + expect(res.status).toBe("ok"); + + // Verify empty + const listAfter = await client.getCredentials(); + const after = listAfter.body as any[]; + expect(after).toEqual([]); + }); +}); diff --git a/test/api-contracts/src/endpoints/deployments.test.ts b/test/api-contracts/src/endpoints/deployments.test.ts new file mode 100644 index 000000000..7de2601a6 --- /dev/null +++ b/test/api-contracts/src/endpoints/deployments.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { getClient, removeDeploymentFile } from "../helpers"; + +const client = getClient(); + +describe("GET /api/deployments", () => { + it("returns deployments array with pre-seeded deployment", async () => { + const res = await client.getDeployments(); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThan(0); + + // Find our pre-seeded deployment + const testDeployment = body.find( + (d: any) => d.deploymentName === "test-deployment", + ); + expect(testDeployment).toBeDefined(); + expect(testDeployment.state).toBe("deployed"); + expect(testDeployment.serverUrl).toBe("https://connect.example.com"); + expect(testDeployment).toMatchSnapshot({ + deploymentName: expect.any(String), + deploymentPath: expect.any(String), + projectDir: expect.any(String), + configurationPath: expect.any(String), + saveName: expect.any(String), + state: "deployed", + serverUrl: expect.any(String), + serverType: "connect", + id: expect.any(String), + dashboardUrl: expect.any(String), + directUrl: expect.any(String), + logsUrl: expect.any(String), + createdAt: expect.any(String), + deployedAt: expect.any(String), + bundleId: expect.any(String), + configurationName: expect.any(String), + }); + }); + + it("returns empty array for directory with no deployments", async () => { + const res = await client.getDeployments({ dir: "static" }); + expect(res.status).toBe("ok"); + expect(res.body).toEqual([]); + }); +}); + +describe("GET /api/deployments/{name}", () => { + it("returns a single deployment by name", async () => { + const res = await client.getDeployment("test-deployment"); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.deploymentName).toBe("test-deployment"); + expect(body.state).toBe("deployed"); + expect(body.serverType).toBe("connect"); + }); + + it("returns 404 for non-existent deployment", async () => { + const res = await client.getDeployment("does-not-exist"); + expect(res.status).toBe("not_found"); + }); +}); + +describe("POST /api/deployments (create deployment record)", () => { + const credName = "deploy-test-server"; + const deployName = "post-test-deployment"; + + beforeAll(async () => { + // Create a credential so we have an account to reference + await client.postCredential({ + name: credName, + url: "https://deploy-test.example.com", + serverType: "connect", + apiKey: "deploy-test-key", + }); + }); + + afterAll(async () => { + removeDeploymentFile(deployName); + // Clean up credential + const creds = await client.getCredentials(); + const credList = creds.body as any[]; + const cred = credList.find((c: any) => c.name === credName); + if (cred) { + await client.deleteCredential(cred.guid); + } + }); + + it("creates a new deployment record", async () => { + const res = await client.postDeployment({ + account: credName, + config: "test-config", + saveName: deployName, + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.deploymentName).toBe(deployName); + expect(body.state).toBe("new"); + expect(body.configurationName).toBe("test-config"); + expect(body.serverUrl).toBe("https://deploy-test.example.com"); + }); + + it("returns 409 when deployment already exists", async () => { + const res = await client.postDeployment({ + account: credName, + config: "test-config", + saveName: deployName, + }); + expect(res.status).toBe("conflict"); + }); +}); + +describe("PATCH /api/deployments/{name}", () => { + it("updates configuration name on existing deployment", async () => { + const res = await client.patchDeployment("test-deployment", { + configurationName: "test-config", + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe("test-config"); + }); + + it("returns 404 for non-existent deployment", async () => { + const res = await client.patchDeployment("does-not-exist", { + configurationName: "test-config", + }); + expect(res.status).toBe("not_found"); + }); +}); + +describe("DELETE /api/deployments/{name}", () => { + const deleteName = "delete-test-deployment"; + + it("deletes an existing deployment", async () => { + // First, create a credential and deployment to delete + const credRes = await client.postCredential({ + name: "delete-deploy-server", + url: "https://delete-deploy.example.com", + serverType: "connect", + apiKey: "delete-deploy-key", + }); + const cred = credRes.body as any; + + await client.postDeployment({ + account: "delete-deploy-server", + config: "test-config", + saveName: deleteName, + }); + + // Verify it exists + const getRes = await client.getDeployment(deleteName); + expect(getRes.status).toBe("ok"); + + // Delete it + const deleteRes = await client.deleteDeployment(deleteName); + expect(deleteRes.status).toBe("no_content"); + + // Verify it's gone + const afterRes = await client.getDeployment(deleteName); + expect(afterRes.status).toBe("not_found"); + + // Clean up credential + await client.deleteCredential(cred.guid); + }); + + it("returns 404 when deleting non-existent deployment", async () => { + const res = await client.deleteDeployment("does-not-exist"); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/endpoints/entrypoints.test.ts b/test/api-contracts/src/endpoints/entrypoints.test.ts new file mode 100644 index 000000000..e685e6a59 --- /dev/null +++ b/test/api-contracts/src/endpoints/entrypoints.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("POST /api/entrypoints", () => { + it("returns entrypoints for the root workspace", async () => { + const res = await client.postEntrypoints(); + expect(res.status).toBe("ok"); + + const body = res.body as string[]; + expect(body).toBeInstanceOf(Array); + + // Should include files from across the fixture subdirectories + expect(body.some((e) => e.endsWith(".py"))).toBe(true); + expect(body.some((e) => e.endsWith(".html"))).toBe(true); + expect(body.some((e) => e.endsWith(".qmd"))).toBe(true); + expect(body.some((e) => e.endsWith(".ipynb"))).toBe(true); + expect(body.some((e) => e.endsWith(".Rmd"))).toBe(true); + + expect(body).toMatchSnapshot(); + }); + + it("returns entrypoints for a specific subdirectory", async () => { + const res = await client.postEntrypoints({ dir: "fastapi-simple" }); + expect(res.status).toBe("ok"); + + const body = res.body as string[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body.every((e) => e.endsWith(".py"))).toBe(true); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/test/api-contracts/src/endpoints/inspect.test.ts b/test/api-contracts/src/endpoints/inspect.test.ts new file mode 100644 index 000000000..d59831d03 --- /dev/null +++ b/test/api-contracts/src/endpoints/inspect.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("POST /api/inspect (per-directory)", () => { + it("inspects fastapi-simple/ — detects python-fastapi", async () => { + const res = await client.postInspect({ dir: "fastapi-simple" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + expect(first.configuration.type).toBe("python-fastapi"); + expect(first.configuration.entrypoint).toMatch(/\.py$/); + + expect(body).toMatchSnapshot(); + }); + + it("inspects static/ — detects html", async () => { + const res = await client.postInspect({ dir: "static" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + expect(first.configuration.type).toBe("html"); + expect(first.configuration.entrypoint).toBe("index.html"); + + expect(body).toMatchSnapshot(); + }); + + it("inspects quarto-doc/ — detects quarto content", async () => { + const res = await client.postInspect({ dir: "quarto-doc" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + expect(String(first.configuration.type)).toMatch(/quarto/); + expect(first.configuration.entrypoint).toBe("index.qmd"); + + expect(body).toMatchSnapshot(); + }); + + it("inspects jupyter-nb/ — detects as quarto-static (Quarto inspects .ipynb)", async () => { + const res = await client.postInspect({ dir: "jupyter-nb" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + // Go inspector routes .ipynb through Quarto, which classifies it as quarto-static + expect(first.configuration.type).toBe("quarto-static"); + expect(String(first.configuration.entrypoint)).toMatch(/\.ipynb$/); + + expect(body).toMatchSnapshot(); + }); + + it("inspects shiny-python/ — detects python-shiny", async () => { + const res = await client.postInspect({ dir: "shiny-python" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + expect(first.configuration.type).toBe("python-shiny"); + expect(first.configuration.entrypoint).toBe("app.py"); + + expect(body).toMatchSnapshot(); + }); + + it("inspects rmd-static/ — detects as quarto-static (Quarto inspects .Rmd)", async () => { + const res = await client.postInspect({ dir: "rmd-static" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(1); + + const first = body[0]; + expect(first).toHaveProperty("projectDir"); + expect(first).toHaveProperty("configuration"); + // Go inspector routes .Rmd through Quarto, which classifies it as quarto-static + expect(first.configuration.type).toBe("quarto-static"); + expect(String(first.configuration.entrypoint)).toMatch(/\.Rmd$/); + + expect(body).toMatchSnapshot(); + }); +}); + +describe("POST /api/inspect (recursive)", () => { + it("inspects root workspace recursively — returns multiple content types", async () => { + const res = await client.postInspect({ recursive: "true" }); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThanOrEqual(4); + + const types = body.map((item: any) => item.configuration?.type); + + expect(types).toEqual( + expect.arrayContaining([ + expect.stringMatching(/python-fastapi/), + expect.stringMatching(/html/), + expect.stringMatching(/quarto/), + ]), + ); + + expect(body).toMatchSnapshot(); + }); +}); + +describe("POST /api/inspect (edge cases)", () => { + it("returns not_found for a nonexistent directory", async () => { + const res = await client.postInspect({ dir: "nonexistent-empty" }); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/fixtures/workspace/.posit/publish/deployments/test-deployment.toml b/test/api-contracts/src/fixtures/workspace/.posit/publish/deployments/test-deployment.toml new file mode 100644 index 000000000..2debf8e82 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/.posit/publish/deployments/test-deployment.toml @@ -0,0 +1,33 @@ +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json" +server_type = "connect" +server_url = "https://connect.example.com" +id = "de2e7bdb-b085-401e-a65c-443e40009749" +client_version = "1.0.0" +type = "python-fastapi" +created_at = "2024-01-19T09:33:33-05:00" +configuration_name = "test-config" +deployed_at = "2024-01-19T09:33:33-05:00" +bundle_id = "100" +dashboard_url = "https://connect.example.com/connect/#/apps/de2e7bdb-b085-401e-a65c-443e40009749" +direct_url = "https://connect.example.com/content/de2e7bdb-b085-401e-a65c-443e40009749/" +logs_url = "https://connect.example.com/connect/#/apps/de2e7bdb-b085-401e-a65c-443e40009749/logs" + +files = [ + "fastapi-simple/app.py", + "fastapi-simple/requirements.txt", +] + +[configuration] +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-fastapi" +entrypoint = "fastapi-simple/app.py" +validate = true +files = [ + "fastapi-simple/app.py", + "fastapi-simple/requirements.txt", +] + +[configuration.python] +version = "3.11.3" +package_file = "fastapi-simple/requirements.txt" +package_manager = "pip" diff --git a/test/api-contracts/src/fixtures/workspace/.posit/publish/test-config.toml b/test/api-contracts/src/fixtures/workspace/.posit/publish/test-config.toml new file mode 100644 index 000000000..98e278e30 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/.posit/publish/test-config.toml @@ -0,0 +1,13 @@ +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-fastapi" +entrypoint = "fastapi-simple/app.py" +validate = true +files = [ + "fastapi-simple/app.py", + "fastapi-simple/requirements.txt", +] + +[python] +version = "3.11.3" +package_file = "fastapi-simple/requirements.txt" +package_manager = "pip" diff --git a/test/api-contracts/src/fixtures/workspace/fastapi-simple/app.py b/test/api-contracts/src/fixtures/workspace/fastapi-simple/app.py new file mode 100644 index 000000000..af0afc8cf --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/fastapi-simple/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"message": "Hello World"} diff --git a/test/api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt b/test/api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt new file mode 100644 index 000000000..97dc7cd8c --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/test/api-contracts/src/fixtures/workspace/jupyter-nb/notebook.ipynb b/test/api-contracts/src/fixtures/workspace/jupyter-nb/notebook.ipynb new file mode 100644 index 000000000..a5d3b302b --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/jupyter-nb/notebook.ipynb @@ -0,0 +1,29 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.DataFrame({\"x\": [1, 2, 3], \"y\": [4, 5, 6]})\n", + "df.head()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/test/api-contracts/src/fixtures/workspace/jupyter-nb/requirements.txt b/test/api-contracts/src/fixtures/workspace/jupyter-nb/requirements.txt new file mode 100644 index 000000000..fb6c7ed7e --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/jupyter-nb/requirements.txt @@ -0,0 +1 @@ +pandas diff --git a/test/api-contracts/src/fixtures/workspace/quarto-doc/_quarto.yml b/test/api-contracts/src/fixtures/workspace/quarto-doc/_quarto.yml new file mode 100644 index 000000000..b9414ecf2 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/quarto-doc/_quarto.yml @@ -0,0 +1,6 @@ +project: + type: default + +format: + html: + theme: cosmo diff --git a/test/api-contracts/src/fixtures/workspace/quarto-doc/index.qmd b/test/api-contracts/src/fixtures/workspace/quarto-doc/index.qmd new file mode 100644 index 000000000..fe02d7082 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/quarto-doc/index.qmd @@ -0,0 +1,8 @@ +--- +title: "Quarto Document" +format: html +--- + +## Introduction + +This is a minimal Quarto document for testing content type detection. diff --git a/test/api-contracts/src/fixtures/workspace/rmd-static/report.Rmd b/test/api-contracts/src/fixtures/workspace/rmd-static/report.Rmd new file mode 100644 index 000000000..8d3a95948 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/rmd-static/report.Rmd @@ -0,0 +1,12 @@ +--- +title: "Report" +output: html_document +--- + +## Introduction + +This is a minimal R Markdown document for testing content type detection. + +```{r} +summary(cars) +``` diff --git a/test/api-contracts/src/fixtures/workspace/shiny-python/app.py b/test/api-contracts/src/fixtures/workspace/shiny-python/app.py new file mode 100644 index 000000000..b70a74846 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/shiny-python/app.py @@ -0,0 +1,12 @@ +from shiny import App, ui + +app_ui = ui.page_fluid( + ui.h1("Hello Shiny!"), +) + + +def server(input, output, session): + pass + + +app = App(app_ui, server) diff --git a/test/api-contracts/src/fixtures/workspace/shiny-python/requirements.txt b/test/api-contracts/src/fixtures/workspace/shiny-python/requirements.txt new file mode 100644 index 000000000..3e78250b6 --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/shiny-python/requirements.txt @@ -0,0 +1 @@ +shiny diff --git a/test/api-contracts/src/fixtures/workspace/static/index.html b/test/api-contracts/src/fixtures/workspace/static/index.html new file mode 100644 index 000000000..05da19bbd --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/static/index.html @@ -0,0 +1,9 @@ + + + + Test Static Content + + +

Hello from contract tests

+ + diff --git a/test/api-contracts/src/helpers.ts b/test/api-contracts/src/helpers.ts new file mode 100644 index 000000000..93693fc97 --- /dev/null +++ b/test/api-contracts/src/helpers.ts @@ -0,0 +1,103 @@ +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { BackendClient } from "./client"; +import { GoHttpClient } from "./clients/go-http-client"; +import { TypeScriptDirectClient } from "./clients/typescript-direct-client"; + +// --- Client accessor --- + +let _client: BackendClient | null = null; + +export function getClient(): BackendClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "go"; + if (clientType === "go") { + const base = process.env.API_BASE; + if (!base) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + _client = new GoHttpClient(base); + } else { + const dir = process.env.WORKSPACE_DIR; + if (!dir) { + throw new Error( + "WORKSPACE_DIR not set. Is the global setup running correctly?", + ); + } + _client = new TypeScriptDirectClient(dir); + } + return _client; +} + +export function getWorkspaceDir(): string { + const dir = process.env.WORKSPACE_DIR; + if (!dir) { + throw new Error( + "WORKSPACE_DIR not set. Is the global setup running correctly?", + ); + } + return dir; +} + +// --- Workspace manipulation --- + +function positPublishDir(): string { + return join(getWorkspaceDir(), ".posit", "publish"); +} + +function deploymentsDir(): string { + return join(positPublishDir(), "deployments"); +} + +/** + * Write a configuration TOML file to the workspace's .posit/publish/ directory. + */ +export function seedConfigFile(name: string, content: string): void { + const dir = positPublishDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.toml`), content, "utf-8"); +} + +/** + * Write a deployment TOML file to the workspace's .posit/publish/deployments/ directory. + */ +export function seedDeploymentFile(name: string, content: string): void { + const dir = deploymentsDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.toml`), content, "utf-8"); +} + +/** + * Remove a configuration TOML file from the workspace. + */ +export function removeConfigFile(name: string): void { + const path = join(positPublishDir(), `${name}.toml`); + if (existsSync(path)) { + rmSync(path); + } +} + +/** + * Remove a deployment TOML file from the workspace. + */ +export function removeDeploymentFile(name: string): void { + const path = join(deploymentsDir(), `${name}.toml`); + if (existsSync(path)) { + rmSync(path); + } +} + +/** + * Remove the entire .posit/publish directory and re-create it empty. + */ +export function resetDotPosit(): void { + const dir = positPublishDir(); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + mkdirSync(dir, { recursive: true }); + mkdirSync(deploymentsDir(), { recursive: true }); +} diff --git a/test/api-contracts/src/setup.ts b/test/api-contracts/src/setup.ts new file mode 100644 index 000000000..7a28775b2 --- /dev/null +++ b/test/api-contracts/src/setup.ts @@ -0,0 +1,152 @@ +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { GlobalSetupContext } from "vitest/node"; + +const REPO_ROOT = resolve(__dirname, "..", "..", ".."); +const FIXTURES_DIR = resolve(__dirname, "fixtures", "workspace"); + +let serverProcess: ChildProcess | null = null; +let tempDir: string | null = null; + +function getExecutablePath(): string { + const result = execSync("just executable-path", { + cwd: REPO_ROOT, + encoding: "utf-8", + }).trim(); + return resolve(REPO_ROOT, result); +} + +function waitForReady(apiBase: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Server did not become ready within ${timeoutMs}ms`)); + return; + } + try { + const res = await fetch(`${apiBase}/api/configurations`); + if (res.ok) { + resolve(); + return; + } + } catch { + // Not ready yet + } + setTimeout(poll, 200); + }; + poll(); + }); +} + +export async function setup({ provide }: GlobalSetupContext) { + // 1. Copy fixture workspace to temp directory + tempDir = mkdtempSync(join(tmpdir(), "publisher-contract-")); + cpSync(FIXTURES_DIR, tempDir, { recursive: true }); + process.env.WORKSPACE_DIR = tempDir; + + const backend = process.env.API_BACKEND ?? "go"; + + if (backend === "go") { + // 2. Find the Go binary + const binaryPath = getExecutablePath(); + + // 3. Spawn the server + serverProcess = spawn( + binaryPath, + ["ui", tempDir, "--listen", "localhost:0", "--use-keychain=false"], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Use a temp home directory so credential files don't pollute user's home + HOME: tempDir, + USERPROFILE: tempDir, + }, + }, + ); + + // 4. Capture the URL from stdout + const apiBase = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for server URL on stdout")); + }, 15_000); + + let buffer = ""; + serverProcess!.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("http://")) { + clearTimeout(timeout); + // Remove trailing slash + resolve(trimmed.replace(/\/$/, "")); + return; + } + } + }); + + serverProcess!.stderr!.on("data", (chunk: Buffer) => { + // Log stderr for debugging + process.stderr.write(`[publisher stderr] ${chunk.toString()}`); + }); + + serverProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn publisher: ${err.message}`)); + }); + + serverProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Publisher exited with code ${code}`)); + } + }); + }); + + // 5. Wait for the server to be ready + await waitForReady(apiBase); + + process.env.API_BASE = apiBase; + process.env.__CLIENT_TYPE = "go"; + + console.log(`[setup] Server running at ${apiBase}`); + } else { + // TypeScript backend — no subprocess needed + process.env.__CLIENT_TYPE = "typescript"; + + console.log(`[setup] Using TypeScript direct client`); + } + + console.log(`[setup] Workspace at ${tempDir}`); +} + +export async function teardown() { + // Kill the server process + if (serverProcess) { + serverProcess.kill("SIGTERM"); + // Wait briefly for graceful shutdown + await new Promise((resolve) => { + const timeout = setTimeout(() => { + serverProcess?.kill("SIGKILL"); + resolve(); + }, 5_000); + serverProcess!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + serverProcess = null; + } + + // Clean up temp directory + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + + console.log("[teardown] Server stopped and workspace cleaned up"); +} diff --git a/test/api-contracts/tsconfig.json b/test/api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/api-contracts/vitest.config.ts b/test/api-contracts/vitest.config.ts new file mode 100644 index 000000000..7713939bd --- /dev/null +++ b/test/api-contracts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + }, +}); diff --git a/test/connect-api-contracts/README.md b/test/connect-api-contracts/README.md new file mode 100644 index 000000000..e3f4d2bb8 --- /dev/null +++ b/test/connect-api-contracts/README.md @@ -0,0 +1,95 @@ +# Connect API Contract Tests + +Contract tests that validate the HTTP requests Publisher sends **to Posit Connect** and how it parses Connect's responses. These ensure a future TypeScript ConnectClient produces identical behavior to the Go implementation. + +This is fundamentally different from the [Publisher API contract tests](../api-contracts/) — those test Publisher's own API surface, while these test Publisher's role as a **client** of Connect's API. + +## Architecture + +``` +Test code → Publisher binary (Go) → Mock Connect server (Node.js) + POST /api/test-credentials GET /__api__/v1/user + (Publisher's own API) (canned Connect response) +``` + +Two servers are involved: + +1. **Mock Connect server** — A Node.js HTTP server that simulates Connect's API endpoints with canned JSON responses and captures all incoming requests for assertion. +2. **Publisher binary** — The system under test, which makes outbound HTTP calls to the mock. + +The mock exposes control endpoints for tests: +- `GET /__test__/requests` — Read all captured requests +- `DELETE /__test__/requests` — Clear captured requests + +## What's tested + +All 15 methods on the Go `APIClient` interface have corresponding test files, mock routes, and fixtures. Only `TestAuthentication` is currently active (triggerable via the Go path); all others are `describe.skip` until the TS ConnectClient is implemented. + +| Endpoint | Connect Path | Status | +|----------|-------------|--------| +| `TestAuthentication` | `GET /__api__/v1/user` | **Active** (8 tests) | +| `GetCurrentUser` | `GET /__api__/v1/user` | Skipped | +| `ContentDetails` | `GET /__api__/v1/content/:id` | Skipped | +| `CreateDeployment` | `POST /__api__/v1/content` | Skipped | +| `UpdateDeployment` | `PATCH /__api__/v1/content/:id` | Skipped | +| `GetEnvVars` | `GET /__api__/v1/content/:id/environment` | Skipped | +| `SetEnvVars` | `PATCH /__api__/v1/content/:id/environment` | Skipped | +| `UploadBundle` | `POST /__api__/v1/content/:id/bundles` | Skipped | +| `DeployBundle` | `POST /__api__/v1/content/:id/deploy` | Skipped | +| `WaitForTask` | `GET /__api__/v1/tasks/:id?first=N` | Skipped | +| `ValidateDeployment` | `GET /content/:id/` | Skipped | +| `GetIntegrations` | `GET /__api__/v1/oauth/integrations` | Skipped | +| `GetSettings` | 7 endpoints (see below) | Skipped | +| `LatestBundleID` | `GET /__api__/v1/content/:id` | Skipped | +| `DownloadBundle` | `GET /__api__/v1/content/:id/bundles/:bid/download` | Skipped | + +`GetSettings` calls 7 endpoints in sequence: `/__api__/v1/user`, `/__api__/server_settings`, `/__api__/server_settings/applications`, `/__api__/server_settings/scheduler[/{appMode}]`, `/__api__/v1/server_settings/python`, `/__api__/v1/server_settings/r`, `/__api__/v1/server_settings/quarto`. + +Each test validates both: +- **Request correctness** — method, path, `Authorization: Key ` header +- **Response parsing** — Publisher correctly transforms Connect's DTO into its internal types + +## Client implementations + +| Client | Description | +|--------|-------------| +| `GoPublisherClient` | Calls Publisher's HTTP API, which internally calls mock Connect | +| `TypeScriptDirectClient` | Stub for future TS ConnectClient (all methods throw) | + +Set `API_BACKEND=typescript` to run against the TS client once implemented. + +## Running + +```bash +# Build the Go binary first +just build + +# Run Connect contract tests +just test-connect-contracts + +# Or directly +cd test/connect-api-contracts && npx vitest run + +# Update snapshots +cd test/connect-api-contracts && npx vitest run --update +``` + +## Adding tests + +1. Add a method to the `ConnectContractClient` interface in `src/client.ts` +2. Implement it in both `src/clients/go-publisher-client.ts` and `src/clients/ts-direct-client.ts` +3. Add a route handler in `src/mock-connect-server.ts` with a canned response fixture +4. Create a test file in `src/endpoints/` +5. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture files + +- `src/fixtures/connect-responses/` — Canned JSON responses for Connect API endpoints +- `src/fixtures/workspace/` — Minimal project for Publisher startup (copied from `test/api-contracts/`) + +## Future expansion + +When the TS ConnectClient is built: +1. Implement `ts-direct-client.ts` to call the TS client directly against the mock +2. Un-skip all test files — fixtures, mock routes, and test assertions are already in place +3. Both Go and TS paths validate against the same snapshots and request expectations diff --git a/test/connect-api-contracts/package-lock.json b/test/connect-api-contracts/package-lock.json new file mode 100644 index 000000000..da8363023 --- /dev/null +++ b/test/connect-api-contracts/package-lock.json @@ -0,0 +1,1598 @@ +{ + "name": "connect-api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-api-contracts", + "devDependencies": { + "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/@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/connect-api-contracts/package.json b/test/connect-api-contracts/package.json new file mode 100644 index 000000000..1fec51887 --- /dev/null +++ b/test/connect-api-contracts/package.json @@ -0,0 +1,14 @@ +{ + "name": "connect-api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/connect-api-contracts/src/client.ts b/test/connect-api-contracts/src/client.ts new file mode 100644 index 000000000..9ae61a33d --- /dev/null +++ b/test/connect-api-contracts/src/client.ts @@ -0,0 +1,102 @@ +import type { CapturedRequest } from "./mock-connect-server"; + +export type ConnectContractStatus = "success" | "error"; + +export interface ConnectContractResult { + status: ConnectContractStatus; + result: T; + capturedRequest: CapturedRequest | null; +} + +export interface ConnectContractClient { + testAuthentication(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + getCurrentUser(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + createDeployment(params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise; + + contentDetails(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + updateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise; + + getEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + setEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise; + + uploadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise; + + deployBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; + + waitForTask(params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise; + + validateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + getIntegrations(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + getSettings(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + latestBundleId(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + downloadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; +} diff --git a/test/connect-api-contracts/src/clients/go-publisher-client.ts b/test/connect-api-contracts/src/clients/go-publisher-client.ts new file mode 100644 index 000000000..58ce0943f --- /dev/null +++ b/test/connect-api-contracts/src/clients/go-publisher-client.ts @@ -0,0 +1,199 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; +import type { CapturedRequest } from "../mock-connect-server"; + +export class GoPublisherClient implements ConnectContractClient { + constructor(private apiBase: string) {} + + private getMockConnectUrl(): string { + const url = process.env.MOCK_CONNECT_URL; + if (!url) { + throw new Error("MOCK_CONNECT_URL not set"); + } + return url; + } + + private async clearMockRequests(): Promise { + const mockUrl = this.getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); + } + + private async getCapturedRequests( + pathFilter: string, + ): Promise { + const mockUrl = this.getMockConnectUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + return requests.filter((r) => r.path.includes(pathFilter)); + } + + async testAuthentication(params: { + connectUrl: string; + apiKey: string; + }): Promise { + await this.clearMockRequests(); + + const res = await fetch(`${this.apiBase}/api/test-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: params.connectUrl, + apiKey: params.apiKey, + insecure: false, + timeout: 30, + }), + }); + + const body = await res.json(); + + const captured = await this.getCapturedRequests("/__api__/v1/user"); + const capturedRequest = captured.length > 0 ? captured[0] : null; + + return { + status: body.error ? "error" : "success", + result: body, + capturedRequest, + }; + } + + async getCurrentUser(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetCurrentUser", + ); + } + + async createDeployment(_params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers CreateDeployment", + ); + } + + async contentDetails(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers ContentDetails", + ); + } + + async updateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers UpdateDeployment", + ); + } + + async getEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetEnvVars", + ); + } + + async setEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers SetEnvVars", + ); + } + + async uploadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers UploadBundle", + ); + } + + async deployBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers DeployBundle", + ); + } + + async waitForTask(_params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers WaitForTask", + ); + } + + async validateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers ValidateDeployment", + ); + } + + async getIntegrations(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetIntegrations", + ); + } + + async getSettings(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetSettings", + ); + } + + async latestBundleId(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers LatestBundleID", + ); + } + + async downloadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers DownloadBundle", + ); + } +} diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts new file mode 100644 index 000000000..2abe70aac --- /dev/null +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -0,0 +1,129 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; + +/** + * Stub client for the future TypeScript ConnectClient implementation. + * Each method will call the TS client directly against the mock Connect server. + * For now, all methods throw "Not implemented yet". + */ +export class TypeScriptDirectClient implements ConnectContractClient { + async testAuthentication(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getCurrentUser(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async createDeployment(_params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async contentDetails(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async updateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async setEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise { + throw new Error("Not implemented yet"); + } + + async uploadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise { + throw new Error("Not implemented yet"); + } + + async deployBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async waitForTask(_params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async validateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getIntegrations(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getSettings(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async latestBundleId(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async downloadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/connect-api-contracts/src/endpoints/authentication.test.ts b/test/connect-api-contracts/src/endpoints/authentication.test.ts new file mode 100644 index 000000000..62bce6c79 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/authentication.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe("TestAuthentication", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses user fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + }; + + // Publisher maps Connect's UserDTO (12 fields) down to User (5 fields) + expect(body.user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + + it("returns serverType as connect", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { serverType: string }; + + expect(body.serverType).toBe("connect"); + }); + + it("returns the mock Connect URL", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { url: string }; + + expect(body.url).toBe(connectUrl); + }); + + it("returns null error on success", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { error: unknown }; + + expect(body.error).toBeNull(); + }); + }); + + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.user).toBeNull(); + }); + + it("returns error for locked user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: true, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("locked"); + expect(body.user).toBeNull(); + }); + + it("returns error for unconfirmed user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: false, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("not confirmed"); + expect(body.user).toBeNull(); + }); + + it("returns error for viewer role user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "viewer", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("permission"); + expect(body.user).toBeNull(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + // Mask dynamic URL for snapshot stability + const body = result.result as Record; + const snapshot = { ...body, url: "{{MOCK_CONNECT_URL}}" }; + + expect(snapshot).toMatchInlineSnapshot(` + { + "error": null, + "serverType": "connect", + "url": "{{MOCK_CONNECT_URL}}", + "user": { + "email": "bob@example.com", + "first_name": "Bob", + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "last_name": "Bobberson", + "username": "bob", + }, + } + `); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/content-details.test.ts b/test/connect-api-contracts/src/endpoints/content-details.test.ts new file mode 100644 index 000000000..953cbd4ea --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/content-details.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe.skip("ContentDetails", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses ConnectContent fields from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as Record; + + expect(body.guid).toBe(contentId); + expect(body.name).toBe("my-fastapi-app"); + expect(body.app_mode).toBe("python-fastapi"); + expect(body.py_version).toBe("3.11.6"); + }); + }); + + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 403 forbidden response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 403, + body: { + code: 4, + error: "You do not have permission to perform this operation", + }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 404 not found response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 404, + body: { code: 4, error: "Content not found" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/create-deployment.test.ts b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts new file mode 100644 index 000000000..b2b3d3560 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("CreateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/content"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { name: "my-app", title: "My App" }; + const result = await client.createDeployment({ + connectUrl, + apiKey, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.status).toBe("success"); + }); + + it("parses content GUID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + const body = result.result as { contentId: string }; + + expect(body.contentId).toBe(contentId); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts new file mode 100644 index 000000000..14e04a0e4 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("DeployBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "201"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/deploy", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/deploy`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends bundle_id in request body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual({ bundle_id: bundleId }); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses task ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const body = result.result as { taskId: string }; + + expect(body.taskId).toBe("task-abc123-def456"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/download-bundle.test.ts b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts new file mode 100644 index 000000000..6fc678ddb --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("DownloadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "101"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/bundles/:bid/download", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("returns raw bytes from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const data = result.result as Uint8Array; + + expect(data).toBeInstanceOf(Uint8Array); + expect(data.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-current-user.test.ts b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts new file mode 100644 index 000000000..0c6aff11e --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetCurrentUser", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses User fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + const user = result.result as { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + + expect(user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts new file mode 100644 index 000000000..3f92ea9ef --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses environment variable name list", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + const envVars = result.result as string[]; + + expect(envVars).toEqual(["DATABASE_URL", "SECRET_KEY", "API_TOKEN"]); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-integrations.test.ts b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts new file mode 100644 index 000000000..da4f695e3 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetIntegrations", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/oauth/integrations", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + "/__api__/v1/oauth/integrations", + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses Integration array with expected fields", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + const integrations = result.result as Array<{ + guid: string; + name: string; + description: string; + auth_type: string; + template: string; + config: Record; + created_time: string; + }>; + + expect(integrations).toBeInstanceOf(Array); + expect(integrations.length).toBe(1); + expect(integrations[0].guid).toBe( + "int-guid-1234-5678-abcd-ef0123456789", + ); + expect(integrations[0].name).toBe("My OAuth Integration"); + expect(integrations[0].auth_type).toBe("OAuth2"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-settings.test.ts b/test/connect-api-contracts/src/endpoints/get-settings.test.ts new file mode 100644 index 000000000..66317b902 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-settings.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + getMockRequests, +} from "../helpers"; + +describe.skip("GetSettings", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/v1/user"); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/server_settings"); + const generalReq = requests.find( + (r) => r.path === "/__api__/server_settings", + ); + expect(generalReq).toBeDefined(); + expect(generalReq!.method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/applications", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/server_settings/applications", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/scheduler", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/server_settings/scheduler", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/python", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/v1/server_settings/python", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/r", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/v1/server_settings/r"); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/quarto", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/v1/server_settings/quarto", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends Authorization header on all 7 requests", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const allRequests = await getMockRequests(); + expect(allRequests.length).toBeGreaterThanOrEqual(7); + + for (const req of allRequests) { + expect(req.headers["authorization"]).toBe(`Key ${apiKey}`); + } + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getSettings({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses composite settings from all endpoints", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getSettings({ connectUrl, apiKey }); + const settings = result.result as Record; + + expect(settings).toBeDefined(); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts new file mode 100644 index 000000000..1eb275c82 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("LatestBundleID", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("extracts bundle_id from content DTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as { bundleId: string }; + + // content-details.json has bundle_id: "101" + expect(body.bundleId).toBe("101"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts new file mode 100644 index 000000000..1b03a6516 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("SetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends env vars as [{name, value}] array body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db", SECRET: "abc" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual( + expect.arrayContaining([ + { name: "DATABASE_URL", value: "postgres://localhost/db" }, + { name: "SECRET", value: "abc" }, + ]), + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/update-deployment.test.ts b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts new file mode 100644 index 000000000..4fd1d2921 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("UpdateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { title: "Updated Title", description: "New description" }; + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts new file mode 100644 index 000000000..328b418d6 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("UploadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/bundles", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends Content-Type application/gzip", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toBe( + "application/gzip", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.status).toBe("success"); + }); + + it("parses bundle ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + const body = result.result as { bundleId: string }; + + expect(body.bundleId).toBe("201"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts new file mode 100644 index 000000000..64c17b342 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe.skip("ValidateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /content/:id/ (non-API path)", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe(`/content/${contentId}/`); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 200 response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + }); + + describe("error handling", () => { + it("returns error when content responds with 500", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 500, + body: "Internal Server Error", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns success when content responds with 404", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 404, + body: "Not Found", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + // 404 is acceptable — content may not be running yet + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts new file mode 100644 index 000000000..3e2a4c707 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe.skip("WaitForTask", () => { + const apiKey = "test-api-key-12345"; + const taskId = "task-abc123-def456"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/tasks/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toMatch( + /^\/__api__\/v1\/tasks\/task-abc123-def456/, + ); + }); + + it("includes first query parameter for pagination", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.path).toContain("first="); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status when task finishes with code 0", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses task output lines", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + const task = result.result as { finished: boolean; output: string[] }; + + expect(task.finished).toBe(true); + expect(task.output).toBeInstanceOf(Array); + expect(task.output.length).toBeGreaterThan(0); + }); + }); + + describe("error handling", () => { + it("returns error when task finishes with non-zero exit code", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/tasks/", + status: 200, + body: { + id: "task-abc123-def456", + output: [ + "Building Python application...", + "Bundle requested Python version 3.11.6", + "Error code: python-package-install-failed", + ], + result: null, + finished: true, + code: 1, + error: "Error code: python-package-install-failed", + last: 3, + }, + }); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("error"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json new file mode 100644 index 000000000..4de212760 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json @@ -0,0 +1,20 @@ +{ + "id": "201", + "content_guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "created_time": "2023-06-15T11:00:00Z", + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "active": false, + "size": 4096, + "metadata": { + "source": null, + "source_repo": null, + "source_branch": null, + "source_commit": null, + "archive_md5": "d41d8cd98f00b204e9800998ecf8427e", + "archive_sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json new file mode 100644 index 000000000..224697a03 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T10:30:00Z", + "bundle_id": null, + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json new file mode 100644 index 000000000..34e3b0284 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "A sample FastAPI application", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T12:00:00Z", + "bundle_id": "101", + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json new file mode 100644 index 000000000..a22a89fe8 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json @@ -0,0 +1,3 @@ +{ + "task_id": "task-abc123-def456" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/environment.json b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json new file mode 100644 index 000000000..8349928ac --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json @@ -0,0 +1 @@ +["DATABASE_URL", "SECRET_KEY", "API_TOKEN"] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json new file mode 100644 index 000000000..0d18c6161 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json @@ -0,0 +1,4 @@ +{ + "code": 3, + "error": "Key is not valid" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json new file mode 100644 index 000000000..98317777e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json @@ -0,0 +1,4 @@ +{ + "code": 4, + "error": "You do not have permission to perform this operation" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json new file mode 100644 index 000000000..ec5e0670c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json @@ -0,0 +1,4 @@ +{ + "code": 0, + "error": "Internal server error" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json new file mode 100644 index 000000000..a03a88e08 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json @@ -0,0 +1,11 @@ +[ + { + "guid": "int-guid-1234-5678-abcd-ef0123456789", + "name": "My OAuth Integration", + "description": "OAuth integration for external service", + "auth_type": "OAuth2", + "template": "custom", + "config": {}, + "created_time": "2023-06-01T09:00:00Z" + } +] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json new file mode 100644 index 000000000..35dacd30a --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json @@ -0,0 +1,6 @@ +{ + "access_types": ["acl", "logged_in", "all"], + "run_as": "rstudio-connect", + "run_as_group": "", + "run_as_current_user": false +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json new file mode 100644 index 000000000..7b142a85e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json @@ -0,0 +1,7 @@ +{ + "installations": [ + { "version": "3.11.6", "cluster_name": "", "image_name": "" }, + { "version": "3.10.12", "cluster_name": "", "image_name": "" } + ], + "api_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json new file mode 100644 index 000000000..f0810535f --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "1.4.550", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json new file mode 100644 index 000000000..20c599785 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "4.3.1", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json new file mode 100644 index 000000000..338c9d74c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json @@ -0,0 +1,24 @@ +{ + "min_processes": 0, + "max_processes": 3, + "max_conns_per_process": 20, + "load_factor": 0.5, + "init_timeout": 60, + "idle_timeout": 120, + "min_processes_limit": 0, + "max_processes_limit": 20, + "connection_timeout": 5, + "read_timeout": 30, + "cpu_request": 0.0, + "max_cpu_request": 0.0, + "cpu_limit": 0.0, + "max_cpu_limit": 0.0, + "memory_request": 0, + "max_memory_request": 0, + "memory_limit": 0, + "max_memory_limit": 0, + "amd_gpu_limit": 0, + "max_amd_gpu_limit": 0, + "nvidia_gpu_limit": 0, + "max_nvidia_gpu_limit": 0 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json new file mode 100644 index 000000000..b3805df71 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json @@ -0,0 +1,19 @@ +{ + "license": { + "allow-apis": true, + "current-user-execution": false, + "enable-launcher": false, + "oauth-integrations": true + }, + "runtimes": ["python", "r"], + "git_enabled": true, + "git_available": true, + "execution_type": "native", + "enable_runtime_constraints": false, + "enable_image_management": false, + "default_image_selection_enabled": false, + "default_environment_management_selection": true, + "default_r_environment_management": true, + "default_py_environment_management": true, + "oauth_integrations_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json new file mode 100644 index 000000000..93e10f41d --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Error code: python-package-install-failed"], + "result": null, + "finished": true, + "code": 1, + "error": "Error code: python-package-install-failed", + "last": 3 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json new file mode 100644 index 000000000..7ee8a80cc --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Launching application...", "Application successfully deployed"], + "result": null, + "finished": true, + "code": 0, + "error": "", + "last": 4 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json new file mode 100644 index 000000000..40bfb0b2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": true, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json new file mode 100644 index 000000000..23c46708e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": false, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json new file mode 100644 index 000000000..a72b0fc2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "viewer", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user.json b/test/connect-api-contracts/src/fixtures/connect-responses/user.json new file mode 100644 index 000000000..34778cc95 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py new file mode 100644 index 000000000..af0afc8cf --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"message": "Hello World"} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt new file mode 100644 index 000000000..97dc7cd8c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/test/connect-api-contracts/src/fixtures/workspace/static/index.html b/test/connect-api-contracts/src/fixtures/workspace/static/index.html new file mode 100644 index 000000000..05da19bbd --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/static/index.html @@ -0,0 +1,9 @@ + + + + Test Static Content + + +

Hello from contract tests

+ + diff --git a/test/connect-api-contracts/src/helpers.ts b/test/connect-api-contracts/src/helpers.ts new file mode 100644 index 000000000..c8db0388d --- /dev/null +++ b/test/connect-api-contracts/src/helpers.ts @@ -0,0 +1,71 @@ +import type { ConnectContractClient } from "./client"; +import type { CapturedRequest } from "./mock-connect-server"; +import { GoPublisherClient } from "./clients/go-publisher-client"; +import { TypeScriptDirectClient } from "./clients/ts-direct-client"; + +let _client: ConnectContractClient | null = null; + +export function getClient(): ConnectContractClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "go"; + if (clientType === "go") { + const apiBase = process.env.API_BASE; + if (!apiBase) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + _client = new GoPublisherClient(apiBase); + } else { + _client = new TypeScriptDirectClient(); + } + return _client; +} + +export function getMockConnectUrl(): string { + const url = process.env.MOCK_CONNECT_URL; + if (!url) { + throw new Error( + "MOCK_CONNECT_URL not set. Is the global setup running correctly?", + ); + } + return url; +} + +export async function clearMockRequests(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); +} + +export async function setMockResponse(override: { + method: string; + pathPattern: string; + status: number; + body?: unknown; + contentType?: string; +}): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-override`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(override), + }); +} + +export async function clearMockOverrides(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-overrides`, { method: "DELETE" }); +} + +export async function getMockRequests( + pathFilter?: string, +): Promise { + const mockUrl = getMockConnectUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; +} diff --git a/test/connect-api-contracts/src/mock-connect-server.ts b/test/connect-api-contracts/src/mock-connect-server.ts new file mode 100644 index 000000000..44d05c472 --- /dev/null +++ b/test/connect-api-contracts/src/mock-connect-server.ts @@ -0,0 +1,335 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface CapturedRequest { + method: string; + path: string; + headers: Record; + body: string | null; +} + +interface RouteHandler { + method: string; + pattern: RegExp; + status: number; + response: unknown; // JSON object, string, Buffer, or null (for no-body responses) + contentType?: string; // defaults to "application/json" +} + +const FIXTURES_DIR = resolve(__dirname, "fixtures", "connect-responses"); + +function loadFixture(name: string): unknown { + const content = readFileSync(resolve(FIXTURES_DIR, name), "utf-8"); + return JSON.parse(content); +} + +// Minimal valid gzip stream (empty gzip file) +const DUMMY_GZIP_BYTES = Buffer.from([ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +export class MockConnectServer { + private server: ReturnType | null = null; + private captured: CapturedRequest[] = []; + private routes: RouteHandler[] = []; + private overrides: RouteHandler[] = []; + private _port = 0; + + constructor() { + this.registerDefaultRoutes(); + } + + get port(): number { + return this._port; + } + + get url(): string { + return `http://localhost:${this._port}`; + } + + private registerDefaultRoutes(): void { + // Routes are matched by first match, so more specific patterns must come first. + + // --- Authentication & User --- + + // GET /__api__/v1/user — TestAuthentication, GetCurrentUser + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/user$/, + status: 200, + response: loadFixture("user.json"), + }); + + // --- OAuth Integrations --- + + // GET /__api__/v1/oauth/integrations — GetIntegrations + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/oauth\/integrations$/, + status: 200, + response: loadFixture("integrations.json"), + }); + + // --- Content (specific sub-resources first, then generic) --- + + // GET /__api__/v1/content/:id/bundles/:bid/download — DownloadBundle + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles\/[^/]+\/download$/, + status: 200, + response: DUMMY_GZIP_BYTES, + contentType: "application/gzip", + }); + + // POST /__api__/v1/content/:id/bundles — UploadBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles$/, + status: 200, + response: loadFixture("bundle-upload.json"), + }); + + // GET /__api__/v1/content/:id/environment — GetEnvVars + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 200, + response: loadFixture("environment.json"), + }); + + // PATCH /__api__/v1/content/:id/environment — SetEnvVars + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 204, + response: null, + }); + + // POST /__api__/v1/content/:id/deploy — DeployBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/deploy$/, + status: 200, + response: loadFixture("deploy.json"), + }); + + // POST /__api__/v1/content — CreateDeployment + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content$/, + status: 200, + response: loadFixture("content-create.json"), + }); + + // PATCH /__api__/v1/content/:id — UpdateDeployment + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 204, + response: null, + }); + + // GET /__api__/v1/content/:id — ContentDetails, LatestBundleID + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 200, + response: loadFixture("content-details.json"), + }); + + // --- Tasks --- + + // GET /__api__/v1/tasks/:id — WaitForTask (always returns finished) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/tasks\/[^?]+/, + status: 200, + response: loadFixture("task-finished.json"), + }); + + // --- Server Settings --- + + // GET /__api__/server_settings/applications — GetSettings (applications) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/applications$/, + status: 200, + response: loadFixture("server-settings-applications.json"), + }); + + // GET /__api__/server_settings/scheduler[/{appMode}] — GetSettings (scheduler) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/scheduler/, + status: 200, + response: loadFixture("server-settings-scheduler.json"), + }); + + // GET /__api__/server_settings — GetSettings (general) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings$/, + status: 200, + response: loadFixture("server-settings.json"), + }); + + // GET /__api__/v1/server_settings/python — GetSettings (python) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/python$/, + status: 200, + response: loadFixture("server-settings-python.json"), + }); + + // GET /__api__/v1/server_settings/r — GetSettings (r) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/r$/, + status: 200, + response: loadFixture("server-settings-r.json"), + }); + + // GET /__api__/v1/server_settings/quarto — GetSettings (quarto) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/quarto$/, + status: 200, + response: loadFixture("server-settings-quarto.json"), + }); + + // --- Content Validation (non-API path) --- + + // GET /content/:id/ — ValidateDeployment + this.routes.push({ + method: "GET", + pattern: /^\/content\/[^/]+\/$/, + status: 200, + response: "OK", + contentType: "text/html", + }); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.listen(0, "localhost", () => { + const addr = this.server!.address(); + if (addr && typeof addr === "object") { + this._port = addr.port; + } + resolve(); + }); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const method = req.method ?? "GET"; + const path = req.url ?? "/"; + + // Control endpoint: GET captured requests + if (method === "GET" && path === "/__test__/requests") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(this.captured)); + return; + } + + // Control endpoint: clear captured requests + if (method === "DELETE" && path === "/__test__/requests") { + this.captured = []; + res.writeHead(204); + res.end(); + return; + } + + // Control endpoint: register a response override + if (method === "POST" && path === "/__test__/response-override") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + this.overrides.push({ + method: body.method, + pattern: new RegExp(body.pathPattern), + status: body.status, + response: body.body ?? null, + contentType: body.contentType, + }); + res.writeHead(204); + res.end(); + }); + return; + } + + // Control endpoint: clear all response overrides + if (method === "DELETE" && path === "/__test__/response-overrides") { + this.overrides = []; + res.writeHead(204); + res.end(); + return; + } + + // Collect request body, then capture and route + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const bodyStr = chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + + // Flatten headers to Record + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(", "); + } + } + + // Capture the request + this.captured.push({ method, path, headers, body: bodyStr }); + + // Find matching route (overrides take priority over default routes) + const route = + this.overrides.find( + (r) => r.method === method && r.pattern.test(path), + ) ?? + this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); + + if (route) { + const contentType = route.contentType ?? "application/json"; + + if (route.response === null || route.response === undefined) { + // No-body response (e.g. 204) + res.writeHead(route.status); + res.end(); + } else if (Buffer.isBuffer(route.response)) { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else if (typeof route.response === "string") { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(JSON.stringify(route.response)); + } + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + } +} diff --git a/test/connect-api-contracts/src/setup.ts b/test/connect-api-contracts/src/setup.ts new file mode 100644 index 000000000..d09777337 --- /dev/null +++ b/test/connect-api-contracts/src/setup.ts @@ -0,0 +1,158 @@ +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { GlobalSetupContext } from "vitest/node"; +import { MockConnectServer } from "./mock-connect-server"; + +const REPO_ROOT = resolve(__dirname, "..", "..", ".."); +const FIXTURES_DIR = resolve(__dirname, "fixtures", "workspace"); + +let serverProcess: ChildProcess | null = null; +let mockServer: MockConnectServer | null = null; +let tempDir: string | null = null; + +function getExecutablePath(): string { + const result = execSync("just executable-path", { + cwd: REPO_ROOT, + encoding: "utf-8", + }).trim(); + return resolve(REPO_ROOT, result); +} + +function waitForReady(apiBase: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Server did not become ready within ${timeoutMs}ms`)); + return; + } + try { + const res = await fetch(`${apiBase}/api/configurations`); + if (res.ok) { + resolve(); + return; + } + } catch { + // Not ready yet + } + setTimeout(poll, 200); + }; + poll(); + }); +} + +export async function setup({ provide }: GlobalSetupContext) { + // 1. Start mock Connect server + mockServer = new MockConnectServer(); + await mockServer.start(); + process.env.MOCK_CONNECT_URL = mockServer.url; + + console.log(`[setup] Mock Connect server running at ${mockServer.url}`); + + // 2. Copy fixture workspace to temp directory + tempDir = mkdtempSync(join(tmpdir(), "publisher-connect-contract-")); + cpSync(FIXTURES_DIR, tempDir, { recursive: true }); + process.env.WORKSPACE_DIR = tempDir; + + const backend = process.env.API_BACKEND ?? "go"; + + if (backend === "go") { + // 3. Find the Go binary + const binaryPath = getExecutablePath(); + + // 4. Spawn the Publisher server + serverProcess = spawn( + binaryPath, + ["ui", tempDir, "--listen", "localhost:0", "--use-keychain=false"], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: tempDir, + USERPROFILE: tempDir, + }, + }, + ); + + // 5. Capture the URL from stdout + const apiBase = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for server URL on stdout")); + }, 15_000); + + let buffer = ""; + serverProcess!.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("http://")) { + clearTimeout(timeout); + resolve(trimmed.replace(/\/$/, "")); + return; + } + } + }); + + serverProcess!.stderr!.on("data", (chunk: Buffer) => { + process.stderr.write(`[publisher stderr] ${chunk.toString()}`); + }); + + serverProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn publisher: ${err.message}`)); + }); + + serverProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Publisher exited with code ${code}`)); + } + }); + }); + + // 6. Wait for the server to be ready + await waitForReady(apiBase); + + process.env.API_BASE = apiBase; + process.env.__CLIENT_TYPE = "go"; + + console.log(`[setup] Publisher server running at ${apiBase}`); + } else { + process.env.__CLIENT_TYPE = "typescript"; + console.log(`[setup] Using TypeScript direct client`); + } + + console.log(`[setup] Workspace at ${tempDir}`); +} + +export async function teardown() { + if (serverProcess) { + serverProcess.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + serverProcess?.kill("SIGKILL"); + resolve(); + }, 5_000); + serverProcess!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + serverProcess = null; + } + + if (mockServer) { + await mockServer.stop(); + mockServer = null; + } + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + + console.log("[teardown] Servers stopped and workspace cleaned up"); +} diff --git a/test/connect-api-contracts/tsconfig.json b/test/connect-api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/connect-api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/connect-api-contracts/vitest.config.ts b/test/connect-api-contracts/vitest.config.ts new file mode 100644 index 000000000..7713939bd --- /dev/null +++ b/test/connect-api-contracts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + }, +}); diff --git a/test/extension-api-contracts/README.md b/test/extension-api-contracts/README.md new file mode 100644 index 000000000..02e7e3820 --- /dev/null +++ b/test/extension-api-contracts/README.md @@ -0,0 +1,99 @@ +# Extension API Contract Tests + +Contract tests that validate how the **extension's HTTP client** constructs requests to Publisher's API and parses responses. While the [Publisher API contract tests](../api-contracts/) validate the server's behavior with a real Go binary, these tests validate the **client side** — ensuring the Axios-based extension correctly builds URLs, query params, headers, and request bodies. + +This is the complement of the [Publisher API contract tests](../api-contracts/), not a replacement. Together they form a complete contract: one side verifies the server, the other verifies the client. + +## Architecture + +``` +Test code → Axios client (mirrors extension) → Mock Publisher server (Node.js) + GET /configurations?dir=... canned JSON response + (same paths, params, body shapes) (matches Go backend shapes) +``` + +A single mock server is involved: + +1. **Mock Publisher server** — A Node.js HTTP server that returns canned JSON responses matching the shapes the Go backend produces, and captures all incoming requests for assertion. + +The mock exposes control endpoints for tests: +- `GET /__test__/requests` — Read all captured requests +- `DELETE /__test__/requests` — Clear captured requests + +Unlike the other contract test suites, no Go binary or temp workspace is needed — the mock server handles everything. + +## What's tested + +The Axios client mirrors the exact HTTP calls made by the extension's resource classes (`extensions/vscode/src/api/resources/`): + +| Client Method | Extension Class | HTTP Request | +|--------------|----------------|--------------| +| `getConfigurations` | `Configurations.getAll` | `GET /configurations?dir=...` | +| `getConfiguration` | `Configurations.get` | `GET /configurations/:name?dir=...` | +| `createOrUpdateConfiguration` | `Configurations.createOrUpdate` | `PUT /configurations/:name?dir=...` | +| `deleteConfiguration` | `Configurations.delete` | `DELETE /configurations/:name?dir=...` | +| `listCredentials` | `Credentials.list` | `GET /credentials` | +| `createCredential` | `Credentials.connectCreate` | `POST /credentials` | +| `getCredential` | `Credentials.get` | `GET /credentials/:guid` | +| `deleteCredential` | `Credentials.delete` | `DELETE /credentials/:guid` | +| `resetCredentials` | `Credentials.reset` | `DELETE /credentials` | +| `testCredentials` | `Credentials.test` | `POST /test-credentials` | +| `getDeployments` | `ContentRecords.getAll` | `GET /deployments?dir=...` | +| `getDeployment` | `ContentRecords.get` | `GET /deployments/:id?dir=...` | +| `createDeployment` | `ContentRecords.createNew` | `POST /deployments?dir=...` | +| `deleteDeployment` | `ContentRecords.delete` | `DELETE /deployments/:name?dir=...` | +| `patchDeployment` | `ContentRecords.patch` | `PATCH /deployments/:name?dir=...` | + +Each test validates: +- **Request correctness** — method, path, query params, headers, body +- **Response parsing** — client returns correctly shaped data with correct status +- **Snapshot** — response shape stability + +## URL path note + +The extension resource classes have an inconsistency: some use leading `/` (e.g., `"/configurations"`) and others don't (e.g., `"credentials"`). With Axios, a leading `/` resolves relative to the origin, bypassing any path component in `baseURL`. The mock server registers routes without any `/api` prefix to match the actual resolved paths. + +## Client implementations + +| Client | Description | +|--------|-------------| +| `AxiosExtensionClient` | Axios-based client mirroring the real extension's HTTP calls | +| `FetchReferenceClient` | Stub for future Positron/fetch-based client (all methods throw) | + +Set `API_BACKEND=fetch` to run against the fetch client once implemented. + +## Running + +```bash +# Install dependencies +cd test/extension-api-contracts && npm install + +# Run tests +just test-extension-contracts + +# Or directly +cd test/extension-api-contracts && npx vitest run + +# Update snapshots +cd test/extension-api-contracts && npx vitest run --update + +# Watch mode +cd test/extension-api-contracts && npx vitest +``` + +## Adding tests + +1. Add a method to the `ExtensionContractClient` interface in `src/client.ts` +2. Implement it in both `src/clients/axios-extension-client.ts` and `src/clients/fetch-reference-client.ts` +3. Add a route handler in `src/mock-publisher-server.ts` with a canned response fixture +4. Create or update a test file in `src/endpoints/` +5. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture files + +- `src/fixtures/publisher-responses/` — Canned JSON responses matching the shapes the Go backend returns + +## Related test suites + +- [Publisher API contract tests](../api-contracts/) — Tests Publisher's server side (real Go binary + raw fetch client) +- [Connect API contract tests](../connect-api-contracts/) — Tests Publisher as a client of Connect (Go binary + mock Connect server) diff --git a/test/extension-api-contracts/package-lock.json b/test/extension-api-contracts/package-lock.json new file mode 100644 index 000000000..556cf1514 --- /dev/null +++ b/test/extension-api-contracts/package-lock.json @@ -0,0 +1,1880 @@ +{ + "name": "extension-api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "extension-api-contracts", + "dependencies": { + "axios": "^1.7.0" + }, + "devDependencies": { + "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/@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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "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/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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-api-contracts/package.json b/test/extension-api-contracts/package.json new file mode 100644 index 000000000..618c2098b --- /dev/null +++ b/test/extension-api-contracts/package.json @@ -0,0 +1,17 @@ +{ + "name": "extension-api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "dependencies": { + "axios": "^1.7.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/extension-api-contracts/src/client.ts b/test/extension-api-contracts/src/client.ts new file mode 100644 index 000000000..9244f2a5a --- /dev/null +++ b/test/extension-api-contracts/src/client.ts @@ -0,0 +1,32 @@ +import type { CapturedRequest } from "./mock-publisher-server"; + +export type ExtensionContractStatus = "success" | "not_found" | "conflict" | "error"; + +export interface ExtensionContractResult { + status: ExtensionContractStatus; + result: T; + capturedRequests: CapturedRequest[]; +} + +export interface ExtensionContractClient { + // Configurations + getConfigurations(dir: string): Promise; + getConfiguration(name: string, dir: string): Promise; + createOrUpdateConfiguration(name: string, body: unknown, dir: string): Promise; + deleteConfiguration(name: string, dir: string): Promise; + + // Credentials + listCredentials(): Promise; + createCredential(body: unknown): Promise; + getCredential(guid: string): Promise; + deleteCredential(guid: string): Promise; + resetCredentials(): Promise; + testCredentials(url: string, insecure: boolean, apiKey?: string): Promise; + + // Deployments + getDeployments(dir: string): Promise; + getDeployment(id: string, dir: string): Promise; + createDeployment(dir: string, account?: string, config?: string, saveName?: string): Promise; + deleteDeployment(saveName: string, dir: string): Promise; + patchDeployment(name: string, dir: string, data: { configName?: string; guid?: string }): Promise; +} diff --git a/test/extension-api-contracts/src/clients/axios-extension-client.ts b/test/extension-api-contracts/src/clients/axios-extension-client.ts new file mode 100644 index 000000000..f445792a4 --- /dev/null +++ b/test/extension-api-contracts/src/clients/axios-extension-client.ts @@ -0,0 +1,293 @@ +import axios, { AxiosError, type AxiosInstance } from "axios"; +import type { + ExtensionContractClient, + ExtensionContractResult, + ExtensionContractStatus, +} from "../client"; +import type { CapturedRequest } from "../mock-publisher-server"; + +function mapStatus(httpStatus: number): ExtensionContractStatus { + switch (httpStatus) { + case 200: + case 201: + case 204: + return "success"; + case 404: + return "not_found"; + case 409: + return "conflict"; + default: + return "error"; + } +} + +export class AxiosExtensionClient implements ExtensionContractClient { + private client: AxiosInstance; + private mockUrl: string; + + constructor(mockUrl: string) { + this.mockUrl = mockUrl; + this.client = axios.create({ + baseURL: mockUrl, + // Match extension behavior: don't throw on non-2xx + validateStatus: () => true, + }); + } + + private async getCapturedRequests( + pathFilter?: string, + ): Promise { + const res = await fetch(`${this.mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; + } + + private async wrapCall( + fn: () => Promise<{ status: number; data: T }>, + pathFilter?: string, + ): Promise> { + try { + const response = await fn(); + const captured = pathFilter + ? await this.getCapturedRequests(pathFilter) + : []; + return { + status: mapStatus(response.status), + result: response.data, + capturedRequests: captured, + }; + } catch (err) { + if (err instanceof AxiosError && err.response) { + const captured = pathFilter + ? await this.getCapturedRequests(pathFilter) + : []; + return { + status: mapStatus(err.response.status), + result: err.response.data as T, + capturedRequests: captured, + }; + } + throw err; + } + } + + // --- Configurations --- + + // Mirrors: Configurations.getAll(dir) → GET /configurations?dir=... + // Note: The real extension uses `/configurations` (leading slash), which with + // Axios resolves relative to the origin, bypassing any path in baseURL. + async getConfigurations(dir: string): Promise { + return this.wrapCall( + () => + this.client.get("/configurations", { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.get(configName, dir) → GET /configurations/:name?dir=... + async getConfiguration( + name: string, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.get(`/configurations/${encodedName}`, { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.createOrUpdate(configName, cfg, dir) → PUT configurations/:name?dir=... + // Note: The real extension uses `configurations/` (no leading slash) for this method. + async createOrUpdateConfiguration( + name: string, + body: unknown, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.put(`configurations/${encodedName}`, body, { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.delete(configName, dir) → DELETE configurations/:name?dir=... + // Note: The real extension uses `configurations/` (no leading slash) for this method. + async deleteConfiguration( + name: string, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.delete(`configurations/${encodedName}`, { + params: { dir }, + }), + "/configurations", + ); + } + + // --- Credentials --- + + // Mirrors: Credentials.list() → GET credentials + // Note: The real extension uses `credentials` (no leading slash). + async listCredentials(): Promise { + return this.wrapCall( + () => this.client.get("credentials"), + "/credentials", + ); + } + + // Mirrors: Credentials.connectCreate(data, serverType) → POST credentials + // Note: The real extension uses `credentials` (no leading slash). + async createCredential(body: unknown): Promise { + return this.wrapCall( + () => this.client.post("credentials", body), + "/credentials", + ); + } + + // Mirrors: Credentials.get(guid) → GET credentials/:guid + async getCredential(guid: string): Promise { + return this.wrapCall( + () => this.client.get(`credentials/${guid}`), + "/credentials", + ); + } + + // Mirrors: Credentials.delete(guid) → DELETE credentials/:guid + async deleteCredential(guid: string): Promise { + return this.wrapCall( + () => this.client.delete(`credentials/${guid}`), + "/credentials", + ); + } + + // Mirrors: Credentials.reset() → DELETE credentials + async resetCredentials(): Promise { + return this.wrapCall( + () => this.client.delete("credentials"), + "/credentials", + ); + } + + // Mirrors: Credentials.test(url, insecure, apiKey?) → POST test-credentials + // Note: The real extension uses `test-credentials` (no leading slash). + async testCredentials( + url: string, + insecure: boolean, + apiKey?: string, + ): Promise { + return this.wrapCall( + () => + this.client.post("test-credentials", { + url, + apiKey, + insecure, + }), + "/test-credentials", + ); + } + + // --- Deployments --- + + // Mirrors: ContentRecords.getAll(dir) → GET /deployments?dir=... + // Note: The real extension uses `/deployments` (leading slash). + async getDeployments(dir: string): Promise { + return this.wrapCall( + () => + this.client.get("/deployments", { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.get(id, dir) → GET deployments/:id?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async getDeployment( + id: string, + dir: string, + ): Promise { + const encodedId = encodeURIComponent(id); + return this.wrapCall( + () => + this.client.get(`deployments/${encodedId}`, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.createNew(dir, account?, config?, saveName?) → POST /deployments?dir=... + // Note: The real extension uses `/deployments` (leading slash). + async createDeployment( + dir: string, + account?: string, + config?: string, + saveName?: string, + ): Promise { + const data = { + account, + config, + saveName, + }; + return this.wrapCall( + () => + this.client.post("/deployments", data, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.delete(saveName, dir) → DELETE deployments/:name?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async deleteDeployment( + saveName: string, + dir: string, + ): Promise { + const encodedSaveName = encodeURIComponent(saveName); + return this.wrapCall( + () => + this.client.delete(`deployments/${encodedSaveName}`, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.patch(name, dir, data) → PATCH deployments/:name?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async patchDeployment( + name: string, + dir: string, + data: { configName?: string; guid?: string }, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.patch( + `deployments/${encodedName}`, + { + configurationName: data.configName, + id: data.guid, + }, + { + params: { dir }, + }, + ), + "/deployments", + ); + } +} diff --git a/test/extension-api-contracts/src/clients/fetch-reference-client.ts b/test/extension-api-contracts/src/clients/fetch-reference-client.ts new file mode 100644 index 000000000..cf089be2a --- /dev/null +++ b/test/extension-api-contracts/src/clients/fetch-reference-client.ts @@ -0,0 +1,74 @@ +import type { ExtensionContractClient, ExtensionContractResult } from "../client"; + +/** + * Stub client for a future fetch-based implementation (e.g., Positron). + * Each method will call the Publisher API using native fetch. + * For now, all methods throw "Not implemented yet". + */ +export class FetchReferenceClient implements ExtensionContractClient { + // Configurations + + async getConfigurations(_dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async getConfiguration(_name: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async createOrUpdateConfiguration(_name: string, _body: unknown, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteConfiguration(_name: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + // Credentials + + async listCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + async createCredential(_body: unknown): Promise { + throw new Error("Not implemented yet"); + } + + async getCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + async resetCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + async testCredentials(_url: string, _insecure: boolean, _apiKey?: string): Promise { + throw new Error("Not implemented yet"); + } + + // Deployments + + async getDeployments(_dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async getDeployment(_id: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async createDeployment(_dir: string, _account?: string, _config?: string, _saveName?: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteDeployment(_saveName: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async patchDeployment(_name: string, _dir: string, _data: { configName?: string; guid?: string }): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap new file mode 100644 index 000000000..bf1db119c --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap @@ -0,0 +1,92 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Configurations > createOrUpdateConfiguration (PUT /configurations/:name) > snapshot > matches expected response shape 1`] = ` +{ + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "new-config", + "configurationPath": "/workspace/.posit/publish/new-config.toml", + "configurationRelPath": ".posit/publish/new-config.toml", + "projectDir": "/workspace", +} +`; + +exports[`Configurations > getConfiguration (GET /configurations/:name) > snapshot > matches expected response shape 1`] = ` +{ + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", +} +`; + +exports[`Configurations > getConfigurations (GET /configurations) > snapshot > matches expected response shape 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "notebook.ipynb", + "files": [ + "notebook.ipynb", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "jupyter-notebook", + }, + "configurationName": "my-notebook", + "configurationPath": "/workspace/.posit/publish/my-notebook.toml", + "configurationRelPath": ".posit/publish/my-notebook.toml", + "projectDir": "/workspace", + }, +] +`; diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap new file mode 100644 index 000000000..ad2a86928 --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Credentials > createCredential (POST /credentials) > snapshot > matches expected response shape 1`] = ` +{ + "apiKey": "new-api-key", + "guid": "new-cred-guid-999", + "name": "new-server", + "serverType": "connect", + "url": "https://new-server.example.com", +} +`; + +exports[`Credentials > getCredential (GET /credentials/:guid) > snapshot > matches expected response shape 1`] = ` +{ + "apiKey": "test-api-key-12345", + "guid": "abc-123-def-456", + "name": "my-connect-server", + "serverType": "connect", + "url": "https://connect.example.com", +} +`; + +exports[`Credentials > listCredentials (GET /credentials) > snapshot > matches expected response shape 1`] = ` +[ + { + "apiKey": "test-api-key-12345", + "guid": "abc-123-def-456", + "name": "my-connect-server", + "serverType": "connect", + "url": "https://connect.example.com", + }, + { + "apiKey": "staging-api-key", + "guid": "ghi-789-jkl-012", + "name": "staging-server", + "serverType": "connect", + "url": "https://staging.example.com", + }, +] +`; + +exports[`Credentials > resetCredentials (DELETE /credentials) > snapshot > matches expected response shape 1`] = ` +{ + "backupFile": "/tmp/credentials-backup-20240101.json", +} +`; + +exports[`Credentials > testCredentials (POST /test-credentials) > snapshot > matches expected response shape 1`] = ` +{ + "error": null, + "serverType": "connect", + "url": "https://connect.example.com", + "user": { + "email": "bob@example.com", + "first_name": "Bob", + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "last_name": "Bobberson", + "username": "bob", + }, +} +`; diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap new file mode 100644 index 000000000..174a9c56a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap @@ -0,0 +1,87 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Deployments > createDeployment (POST /deployments) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-15T10:00:00Z", + "dashboardUrl": "", + "deployedAt": "", + "deploymentName": "new-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/new-deployment.toml", + "directUrl": "", + "id": "", + "logsUrl": "", + "projectDir": "/workspace", + "saveName": "new-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "new", +} +`; + +exports[`Deployments > getDeployment (GET /deployments/:id) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "bundle-456", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", +} +`; + +exports[`Deployments > getDeployments (GET /deployments) > snapshot > matches expected response shape 1`] = ` +[ + { + "bundleId": "bundle-456", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", + }, +] +`; + +exports[`Deployments > patchDeployment (PATCH /deployments/:name) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "bundle-456", + "configurationName": "updated-config", + "configurationPath": "/workspace/.posit/publish/updated-config.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", +} +`; diff --git a/test/extension-api-contracts/src/endpoints/configurations.test.ts b/test/extension-api-contracts/src/endpoints/configurations.test.ts new file mode 100644 index 000000000..b6df2c92a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/configurations.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Configurations", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("getConfigurations (GET /configurations)", () => { + describe("request correctness", () => { + it("sends GET to /configurations", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/configurations"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("accepts content-type application/json", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].headers["accept"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns array of configurations", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.result).toBeInstanceOf(Array); + + const configs = result.result as any[]; + expect(configs.length).toBeGreaterThan(0); + }); + + it("parses configuration fields", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + + const configs = result.result as any[]; + const first = configs[0]; + expect(first.configurationName).toBe("my-app"); + expect(first.configuration).toBeDefined(); + expect(first.configuration.type).toBe("python-fastapi"); + expect(first.configuration.entrypoint).toBe("app.py"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getConfiguration (GET /configurations/:name)", () => { + describe("request correctness", () => { + it("sends GET to /configurations/:name", async () => { + const client = getClient(); + await client.getConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/configurations/my-app"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("encodes special characters in name", async () => { + const client = getClient(); + await client.getConfiguration("my app/config", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].path).toBe("/configurations/my%20app%2Fconfig"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns a single configuration object", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + + const config = result.result as any; + expect(config.configurationName).toBe("my-app"); + expect(config.configuration).toBeDefined(); + expect(config.configuration.type).toBe("python-fastapi"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createOrUpdateConfiguration (PUT /configurations/:name)", () => { + const newConfig = { + type: "python-fastapi", + entrypoint: "app.py", + files: ["app.py", "requirements.txt"], + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + }, + }; + + describe("request correctness", () => { + it("sends PUT to /configurations/:name", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("PUT"); + expect(requests[0].path).toBe("/configurations/new-config"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends configuration as JSON body", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].body).not.toBeNull(); + const body = JSON.parse(requests[0].body!); + expect(body.type).toBe("python-fastapi"); + expect(body.entrypoint).toBe("app.py"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns the created configuration", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const config = result.result as any; + expect(config.configurationName).toBe("new-config"); + expect(config.configuration).toBeDefined(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteConfiguration (DELETE /configurations/:name)", () => { + describe("request correctness", () => { + it("sends DELETE to /configurations/:name", async () => { + const client = getClient(); + await client.deleteConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/configurations/my-app"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.deleteConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteConfiguration("my-app", "/workspace"); + expect(result.status).toBe("success"); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/endpoints/credentials.test.ts b/test/extension-api-contracts/src/endpoints/credentials.test.ts new file mode 100644 index 000000000..4ad7d1681 --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/credentials.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Credentials", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("listCredentials (GET /credentials)", () => { + describe("request correctness", () => { + it("sends GET to /credentials", async () => { + const client = getClient(); + await client.listCredentials(); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/credentials"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.status).toBe("success"); + }); + + it("returns array of credentials", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.result).toBeInstanceOf(Array); + + const creds = result.result as any[]; + expect(creds.length).toBeGreaterThan(0); + }); + + it("parses credential fields", async () => { + const client = getClient(); + const result = await client.listCredentials(); + + const creds = result.result as any[]; + const first = creds[0]; + expect(first.guid).toBe("abc-123-def-456"); + expect(first.name).toBe("my-connect-server"); + expect(first.url).toBe("https://connect.example.com"); + expect(first.serverType).toBe("connect"); + expect(first.apiKey).toBe("test-api-key-12345"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createCredential (POST /credentials)", () => { + const newCred = { + name: "new-server", + url: "https://new-server.example.com", + apiKey: "new-api-key", + serverType: "connect", + }; + + describe("request correctness", () => { + it("sends POST to /credentials", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/credentials"); + }); + + it("sends credential data as JSON body", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests[0].body).not.toBeNull(); + const body = JSON.parse(requests[0].body!); + expect(body.name).toBe("new-server"); + expect(body.url).toBe("https://new-server.example.com"); + expect(body.apiKey).toBe("new-api-key"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 201 response", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + expect(result.status).toBe("success"); + }); + + it("returns the created credential", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + + const cred = result.result as any; + expect(cred.guid).toBeDefined(); + expect(cred.name).toBe("new-server"); + expect(cred.url).toBe("https://new-server.example.com"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getCredential (GET /credentials/:guid)", () => { + describe("request correctness", () => { + it("sends GET to /credentials/:guid", async () => { + const client = getClient(); + await client.getCredential("abc-123-def-456"); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/credentials/abc-123-def-456"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + expect(result.status).toBe("success"); + }); + + it("returns a single credential", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + + const cred = result.result as any; + expect(cred.guid).toBe("abc-123-def-456"); + expect(cred.name).toBe("my-connect-server"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteCredential (DELETE /credentials/:guid)", () => { + describe("request correctness", () => { + it("sends DELETE to /credentials/:guid", async () => { + const client = getClient(); + await client.deleteCredential("abc-123-def-456"); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/credentials/abc-123-def-456"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteCredential("abc-123-def-456"); + expect(result.status).toBe("success"); + }); + }); + }); + + describe("resetCredentials (DELETE /credentials)", () => { + describe("request correctness", () => { + it("sends DELETE to /credentials (no guid)", async () => { + const client = getClient(); + await client.resetCredentials(); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/credentials"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + expect(result.status).toBe("success"); + }); + + it("returns backup file path", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + + const body = result.result as any; + expect(body.backupFile).toBeDefined(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("testCredentials (POST /test-credentials)", () => { + describe("request correctness", () => { + it("sends POST to /test-credentials", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", false, "test-key"); + + const requests = await getMockRequests("/test-credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/test-credentials"); + }); + + it("sends url, insecure, and apiKey in body", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", false, "test-key"); + + const requests = await getMockRequests("/test-credentials"); + const body = JSON.parse(requests[0].body!); + expect(body.url).toBe("https://connect.example.com"); + expect(body.insecure).toBe(false); + expect(body.apiKey).toBe("test-key"); + }); + + it("sends url and insecure without apiKey when not provided", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", true); + + const requests = await getMockRequests("/test-credentials"); + const body = JSON.parse(requests[0].body!); + expect(body.url).toBe("https://connect.example.com"); + expect(body.insecure).toBe(true); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + expect(result.status).toBe("success"); + }); + + it("parses user fields from test result", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + + const body = result.result as any; + expect(body.user).toBeDefined(); + expect(body.user.username).toBe("bob"); + expect(body.error).toBeNull(); + expect(body.serverType).toBe("connect"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/endpoints/deployments.test.ts b/test/extension-api-contracts/src/endpoints/deployments.test.ts new file mode 100644 index 000000000..d0738d49a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/deployments.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Deployments", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("getDeployments (GET /deployments)", () => { + describe("request correctness", () => { + it("sends GET to /deployments", async () => { + const client = getClient(); + await client.getDeployments("/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/deployments"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getDeployments("/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns array of deployments", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.result).toBeInstanceOf(Array); + + const deployments = result.result as any[]; + expect(deployments.length).toBeGreaterThan(0); + }); + + it("parses deployment fields", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + + const deployments = result.result as any[]; + const first = deployments[0]; + expect(first.deploymentName).toBe("my-deployment"); + expect(first.state).toBe("deployed"); + expect(first.serverType).toBe("connect"); + expect(first.serverUrl).toBe("https://connect.example.com"); + expect(first.configurationName).toBe("my-app"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getDeployment (GET /deployments/:id)", () => { + describe("request correctness", () => { + it("sends GET to /deployments/:id", async () => { + const client = getClient(); + await client.getDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("encodes special characters in id", async () => { + const client = getClient(); + await client.getDeployment("my deploy/name", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].path).toBe("/deployments/my%20deploy%2Fname"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns a single deployment object", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + + const deployment = result.result as any; + expect(deployment.deploymentName).toBe("my-deployment"); + expect(deployment.state).toBe("deployed"); + expect(deployment.serverType).toBe("connect"); + expect(deployment.id).toBe("content-id-123"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createDeployment (POST /deployments)", () => { + describe("request correctness", () => { + it("sends POST to /deployments", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/deployments"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends account, config, and saveName in body", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + const body = JSON.parse(requests[0].body!); + expect(body.account).toBe("my-account"); + expect(body.config).toBe("my-config"); + expect(body.saveName).toBe("new-deployment"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + expect(result.status).toBe("success"); + }); + + it("returns the created deployment", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const deployment = result.result as any; + expect(deployment.deploymentName).toBe("new-deployment"); + expect(deployment.state).toBe("new"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteDeployment (DELETE /deployments/:name)", () => { + describe("request correctness", () => { + it("sends DELETE to /deployments/:name", async () => { + const client = getClient(); + await client.deleteDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.deleteDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteDeployment("my-deployment", "/workspace"); + expect(result.status).toBe("success"); + }); + }); + }); + + describe("patchDeployment (PATCH /deployments/:name)", () => { + describe("request correctness", () => { + it("sends PATCH to /deployments/:name", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("PATCH"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends configurationName and id in body (matching extension mapping)", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + guid: "new-guid-456", + }); + + const requests = await getMockRequests("/deployments"); + const body = JSON.parse(requests[0].body!); + expect(body.configurationName).toBe("updated-config"); + expect(body.id).toBe("new-guid-456"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + expect(result.status).toBe("success"); + }); + + it("returns the patched deployment", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const deployment = result.result as any; + expect(deployment.configurationName).toBe("updated-config"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + expect(result.result).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json new file mode 100644 index 000000000..899e01602 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json @@ -0,0 +1,17 @@ +{ + "configurationName": "new-config", + "configurationPath": "/workspace/.posit/publish/new-config.toml", + "configurationRelPath": ".posit/publish/new-config.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json new file mode 100644 index 000000000..c2cd808fc --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json @@ -0,0 +1,17 @@ +{ + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json new file mode 100644 index 000000000..af9616187 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json @@ -0,0 +1,36 @@ +[ + { + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } + }, + { + "configurationName": "my-notebook", + "configurationPath": "/workspace/.posit/publish/my-notebook.toml", + "configurationRelPath": ".posit/publish/my-notebook.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "jupyter-notebook", + "entrypoint": "notebook.ipynb", + "files": ["notebook.ipynb", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json new file mode 100644 index 000000000..2e8d38a26 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json @@ -0,0 +1,7 @@ +{ + "guid": "new-cred-guid-999", + "name": "new-server", + "url": "https://new-server.example.com", + "serverType": "connect", + "apiKey": "new-api-key" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json new file mode 100644 index 000000000..941d3cc34 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json @@ -0,0 +1,7 @@ +{ + "guid": "abc-123-def-456", + "name": "my-connect-server", + "url": "https://connect.example.com", + "serverType": "connect", + "apiKey": "test-api-key-12345" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json new file mode 100644 index 000000000..6f18a68c0 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json @@ -0,0 +1,16 @@ +[ + { + "guid": "abc-123-def-456", + "name": "my-connect-server", + "url": "https://connect.example.com", + "serverType": "connect", + "apiKey": "test-api-key-12345" + }, + { + "guid": "ghi-789-jkl-012", + "name": "staging-server", + "url": "https://staging.example.com", + "serverType": "connect", + "apiKey": "staging-api-key" + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json new file mode 100644 index 000000000..c27a07432 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json @@ -0,0 +1,3 @@ +{ + "backupFile": "/tmp/credentials-backup-20240101.json" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json new file mode 100644 index 000000000..7f650af20 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "new-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/new-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "new-deployment", + "state": "new", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "", + "dashboardUrl": "", + "directUrl": "", + "logsUrl": "", + "createdAt": "2024-01-15T10:00:00Z", + "deployedAt": "", + "bundleId": "", + "configurationName": "my-app" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json new file mode 100644 index 000000000..d6c3b20fa --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/updated-config.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "updated-config" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json new file mode 100644 index 000000000..bed520589 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "my-app" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json new file mode 100644 index 000000000..b3bdbd738 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json @@ -0,0 +1,20 @@ +[ + { + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "my-app" + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json b/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json new file mode 100644 index 000000000..5c48d2452 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json @@ -0,0 +1,12 @@ +{ + "error": null, + "serverType": "connect", + "url": "https://connect.example.com", + "user": { + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "email": "bob@example.com" + } +} diff --git a/test/extension-api-contracts/src/helpers.ts b/test/extension-api-contracts/src/helpers.ts new file mode 100644 index 000000000..26d3e97c6 --- /dev/null +++ b/test/extension-api-contracts/src/helpers.ts @@ -0,0 +1,46 @@ +import type { ExtensionContractClient } from "./client"; +import type { CapturedRequest } from "./mock-publisher-server"; +import { AxiosExtensionClient } from "./clients/axios-extension-client"; +import { FetchReferenceClient } from "./clients/fetch-reference-client"; + +let _client: ExtensionContractClient | null = null; + +export function getClient(): ExtensionContractClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "axios"; + if (clientType === "axios") { + const mockUrl = getMockPublisherUrl(); + _client = new AxiosExtensionClient(mockUrl); + } else { + _client = new FetchReferenceClient(); + } + return _client; +} + +export function getMockPublisherUrl(): string { + const url = process.env.MOCK_PUBLISHER_URL; + if (!url) { + throw new Error( + "MOCK_PUBLISHER_URL not set. Is the global setup running correctly?", + ); + } + return url; +} + +export async function clearMockRequests(): Promise { + const mockUrl = getMockPublisherUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); +} + +export async function getMockRequests( + pathFilter?: string, +): Promise { + const mockUrl = getMockPublisherUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; +} diff --git a/test/extension-api-contracts/src/mock-publisher-server.ts b/test/extension-api-contracts/src/mock-publisher-server.ts new file mode 100644 index 000000000..949c5ab6f --- /dev/null +++ b/test/extension-api-contracts/src/mock-publisher-server.ts @@ -0,0 +1,282 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { URL } from "node:url"; + +export interface CapturedRequest { + method: string; + path: string; + query: Record; + headers: Record; + body: string | null; +} + +interface RouteHandler { + method: string; + pattern: RegExp; + status: number; + response: unknown; + contentType?: string; +} + +const FIXTURES_DIR = resolve(__dirname, "fixtures", "publisher-responses"); + +function loadFixture(name: string): unknown { + const content = readFileSync(resolve(FIXTURES_DIR, name), "utf-8"); + return JSON.parse(content); +} + +export class MockPublisherServer { + private server: ReturnType | null = null; + private captured: CapturedRequest[] = []; + private routes: RouteHandler[] = []; + private _port = 0; + + constructor() { + this.registerDefaultRoutes(); + } + + get port(): number { + return this._port; + } + + get url(): string { + return `http://localhost:${this._port}`; + } + + private registerDefaultRoutes(): void { + // Routes are matched by first match, so more specific patterns must come first. + + // --- Configurations --- + + // GET /configurations — getAll + this.routes.push({ + method: "GET", + pattern: /^\/configurations$/, + status: 200, + response: loadFixture("configurations-list.json"), + }); + + // GET /configurations/:name — get single + this.routes.push({ + method: "GET", + pattern: /^\/configurations\/[^/]+$/, + status: 200, + response: loadFixture("configuration-single.json"), + }); + + // PUT /configurations/:name — createOrUpdate + this.routes.push({ + method: "PUT", + pattern: /^\/configurations\/[^/]+$/, + status: 200, + response: loadFixture("configuration-created.json"), + }); + + // DELETE /configurations/:name — delete + this.routes.push({ + method: "DELETE", + pattern: /^\/configurations\/[^/]+$/, + status: 204, + response: null, + }); + + // --- Credentials --- + + // POST /test-credentials — test + this.routes.push({ + method: "POST", + pattern: /^\/test-credentials$/, + status: 200, + response: loadFixture("test-credentials.json"), + }); + + // GET /credentials — list + this.routes.push({ + method: "GET", + pattern: /^\/credentials$/, + status: 200, + response: loadFixture("credentials-list.json"), + }); + + // POST /credentials — create + this.routes.push({ + method: "POST", + pattern: /^\/credentials$/, + status: 201, + response: loadFixture("credential-created.json"), + }); + + // DELETE /credentials — reset (must come before single-credential DELETE) + // We distinguish by checking if there's a path segment after /credentials + // The pattern for reset is exactly /credentials with no trailing segment + // But we need the single DELETE to match /credentials/:guid + // Since both are DELETE, we handle this in the route matching by ordering: + // specific (with guid) first, then general (reset) + + // DELETE /credentials/:guid — delete single + this.routes.push({ + method: "DELETE", + pattern: /^\/credentials\/[^/]+$/, + status: 204, + response: null, + }); + + // DELETE /credentials — reset all + this.routes.push({ + method: "DELETE", + pattern: /^\/credentials$/, + status: 200, + response: loadFixture("credentials-reset.json"), + }); + + // GET /credentials/:guid — get single + this.routes.push({ + method: "GET", + pattern: /^\/credentials\/[^/]+$/, + status: 200, + response: loadFixture("credential-single.json"), + }); + + // --- Deployments --- + + // GET /deployments — getAll + this.routes.push({ + method: "GET", + pattern: /^\/deployments$/, + status: 200, + response: loadFixture("deployments-list.json"), + }); + + // POST /deployments — createNew + this.routes.push({ + method: "POST", + pattern: /^\/deployments$/, + status: 200, + response: loadFixture("deployment-created.json"), + }); + + // GET /deployments/:id — get single + this.routes.push({ + method: "GET", + pattern: /^\/deployments\/[^/]+$/, + status: 200, + response: loadFixture("deployment-single.json"), + }); + + // PATCH /deployments/:name — patch + this.routes.push({ + method: "PATCH", + pattern: /^\/deployments\/[^/]+$/, + status: 200, + response: loadFixture("deployment-patched.json"), + }); + + // DELETE /deployments/:name — delete + this.routes.push({ + method: "DELETE", + pattern: /^\/deployments\/[^/]+$/, + status: 204, + response: null, + }); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.listen(0, "localhost", () => { + const addr = this.server!.address(); + if (addr && typeof addr === "object") { + this._port = addr.port; + } + resolve(); + }); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const method = req.method ?? "GET"; + const rawUrl = req.url ?? "/"; + + // Parse URL to separate path from query string + const parsed = new URL(rawUrl, `http://localhost:${this._port}`); + const path = parsed.pathname; + const query: Record = {}; + for (const [key, value] of parsed.searchParams.entries()) { + query[key] = value; + } + + // Control endpoint: GET captured requests + if (method === "GET" && path === "/__test__/requests") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(this.captured)); + return; + } + + // Control endpoint: clear captured requests + if (method === "DELETE" && path === "/__test__/requests") { + this.captured = []; + res.writeHead(204); + res.end(); + return; + } + + // Collect request body, then capture and route + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const bodyStr = chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + + // Flatten headers to Record + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(", "); + } + } + + // Capture the request + this.captured.push({ method, path, query, headers, body: bodyStr }); + + // Find matching route + const route = this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); + + if (route) { + const contentType = route.contentType ?? "application/json"; + + if (route.response === null || route.response === undefined) { + // No-body response (e.g. 204) + res.writeHead(route.status); + res.end(); + } else if (Buffer.isBuffer(route.response)) { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else if (typeof route.response === "string") { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(JSON.stringify(route.response)); + } + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + } +} diff --git a/test/extension-api-contracts/src/setup.ts b/test/extension-api-contracts/src/setup.ts new file mode 100644 index 000000000..19e7e8317 --- /dev/null +++ b/test/extension-api-contracts/src/setup.ts @@ -0,0 +1,25 @@ +import { MockPublisherServer } from "./mock-publisher-server"; + +let mockServer: MockPublisherServer | null = null; + +export async function setup() { + mockServer = new MockPublisherServer(); + await mockServer.start(); + + process.env.MOCK_PUBLISHER_URL = mockServer.url; + + const backend = process.env.API_BACKEND ?? "axios"; + process.env.__CLIENT_TYPE = backend; + + console.log(`[setup] Mock Publisher server running at ${mockServer.url}`); + console.log(`[setup] Client type: ${backend}`); +} + +export async function teardown() { + if (mockServer) { + await mockServer.stop(); + mockServer = null; + } + + console.log("[teardown] Mock Publisher server stopped"); +} diff --git a/test/extension-api-contracts/tsconfig.json b/test/extension-api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/extension-api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/extension-api-contracts/vitest.config.ts b/test/extension-api-contracts/vitest.config.ts new file mode 100644 index 000000000..55112af49 --- /dev/null +++ b/test/extension-api-contracts/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + fileParallelism: false, + }, +});