diff --git a/justfile b/justfile index 0cf37c8a8..b7b2bdfa0 100644 --- a/justfile +++ b/justfile @@ -234,6 +234,14 @@ 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 + # 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..1482eb1ac --- /dev/null +++ b/test/api-contracts/src/client.ts @@ -0,0 +1,67 @@ +export type ResultStatus = + | "ok" + | "created" + | "no_content" + | "not_found" + | "conflict" + | "bad_request"; + +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; + + // Files + getFiles(params?: { pathname?: string }): Promise; + + // Configuration sub-resources + getConfigFiles(configName: string, params?: { dir?: string }): Promise; + postConfigFiles(configName: string, body: unknown): Promise; + getConfigSecrets(configName: string, params?: { dir?: string }): Promise; + postConfigSecrets(configName: string, body: unknown): Promise; + getConfigPythonPackages(configName: string, params?: { dir?: string }): Promise; + getConfigRPackages(configName: string, params?: { dir?: string }): Promise; + getIntegrationRequests(configName: string, params?: { dir?: string }): Promise; + postIntegrationRequest(configName: string, body: unknown): Promise; + deleteIntegrationRequest(configName: string, body: unknown): Promise; + + // Credentials (by GUID) + getCredential(guid: string): Promise; + + // Interpreters + getInterpreters(params?: { dir?: string }): Promise; + + // Accounts + getAccounts(): Promise; + getAccount(name: 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..9ef25a25e --- /dev/null +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -0,0 +1,370 @@ +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 400: + return "bad_request"; + 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) { + if (contentType.includes("application/json")) { + body = await res.json(); + } else { + // Some Go handlers call WriteHeader before setting Content-Type, + // so the header may be missing. Try parsing as JSON anyway. + const text = await res.text(); + if (text) { + try { + body = JSON.parse(text); + } catch { + // Not JSON — leave body as null + } + } + } + } + + 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); + } + + // Files + + async getFiles( + params?: { pathname?: string }, + ): Promise { + const query = params?.pathname + ? `?pathname=${encodeURIComponent(params.pathname)}` + : ""; + const res = await fetch( + `${this.apiBase}/api/files${query}`, + ); + return toContractResult(res); + } + + // Configuration sub-resources + + async getConfigFiles( + configName: string, + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/files${query}`, + ); + return toContractResult(res); + } + + async postConfigFiles( + configName: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/files`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + async getConfigSecrets( + configName: string, + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/secrets${query}`, + ); + return toContractResult(res); + } + + async postConfigSecrets( + configName: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/secrets`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + async getConfigPythonPackages( + configName: string, + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/packages/python${query}`, + ); + return toContractResult(res); + } + + async getConfigRPackages( + configName: string, + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/packages/r${query}`, + ); + return toContractResult(res); + } + + async getIntegrationRequests( + configName: string, + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/integration-requests${query}`, + ); + return toContractResult(res); + } + + async postIntegrationRequest( + configName: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/integration-requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + async deleteIntegrationRequest( + configName: string, + body: unknown, + ): Promise { + const res = await fetch( + `${this.apiBase}/api/configurations/${configName}/integration-requests`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return toContractResult(res); + } + + // Credentials (by GUID) + + async getCredential(guid: string): Promise { + const res = await fetch( + `${this.apiBase}/api/credentials/${guid}`, + ); + return toContractResult(res); + } + + // Interpreters + + async getInterpreters( + params?: { dir?: string }, + ): Promise { + const query = params?.dir ? `?dir=${params.dir}` : ""; + const res = await fetch( + `${this.apiBase}/api/interpreters${query}`, + ); + return toContractResult(res); + } + + // Accounts + + async getAccounts(): Promise { + const res = await fetch(`${this.apiBase}/api/accounts`); + return toContractResult(res); + } + + async getAccount(name: string): Promise { + const res = await fetch( + `${this.apiBase}/api/accounts/${name}`, + ); + 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..1c49803c1 --- /dev/null +++ b/test/api-contracts/src/clients/typescript-direct-client.ts @@ -0,0 +1,193 @@ +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"); + } + + // Files + + async getFiles( + _params?: { pathname?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + // Configuration sub-resources + + async getConfigFiles( + _configName: string, + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async postConfigFiles( + _configName: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getConfigSecrets( + _configName: string, + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async postConfigSecrets( + _configName: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getConfigPythonPackages( + _configName: string, + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getConfigRPackages( + _configName: string, + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async getIntegrationRequests( + _configName: string, + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + async postIntegrationRequest( + _configName: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteIntegrationRequest( + _configName: string, + _body: unknown, + ): Promise { + throw new Error("Not implemented yet"); + } + + // Credentials (by GUID) + + async getCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + // Interpreters + + async getInterpreters( + _params?: { dir?: string }, + ): Promise { + throw new Error("Not implemented yet"); + } + + // Accounts + + async getAccounts(): Promise { + throw new Error("Not implemented yet"); + } + + async getAccount(_name: string): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/api-contracts/src/endpoints/__snapshots__/accounts.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/accounts.test.ts.snap new file mode 100644 index 000000000..11d9cc6d2 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/accounts.test.ts.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Accounts > GET /api/accounts/{name} returns a single account after credential creation 1`] = ` +{ + "accountName": Any, + "authType": Any, + "caCert": Any, + "insecure": Any, + "name": Any, + "source": Any, + "type": Any, + "url": Any, +} +`; 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__/credential-by-guid.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/credential-by-guid.test.ts.snap new file mode 100644 index 000000000..4b5906c85 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/credential-by-guid.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GET /api/credentials/{guid} > returns a credential by GUID 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__/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..dd2de0cb7 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap @@ -0,0 +1,462 @@ +// 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", + "/renv.lock", + ], + "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", + "/renv.lock", + ], + "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", + "/renv.lock", + ], + "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", + "/renv.lock", + ], + "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/__snapshots__/interpreters.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/interpreters.test.ts.snap new file mode 100644 index 000000000..2e98b8096 --- /dev/null +++ b/test/api-contracts/src/endpoints/__snapshots__/interpreters.test.ts.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GET /api/interpreters > returns interpreter information 1`] = ` +{ + "python": ObjectContaining { + "packageManager": Any, + "version": Any, + }, + "r": ObjectContaining { + "packageManager": Any, + "version": Any, + }, +} +`; diff --git a/test/api-contracts/src/endpoints/accounts.test.ts b/test/api-contracts/src/endpoints/accounts.test.ts new file mode 100644 index 000000000..7b935fcef --- /dev/null +++ b/test/api-contracts/src/endpoints/accounts.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("Accounts", () => { + let credGuid: string | null = null; + + afterAll(async () => { + // Clean up the credential created by this test + if (credGuid) { + await client.deleteCredential(credGuid); + credGuid = null; + } + }); + + it("GET /api/accounts returns accounts array", async () => { + const res = await client.getAccounts(); + expect(res.status).toBe("ok"); + expect(res.body).toBeInstanceOf(Array); + }); + + it("GET /api/accounts/{name} returns a single account after credential creation", async () => { + const credRes = await client.postCredential({ + name: "accounts-test-server", + url: "https://accounts-test.example.com", + serverType: "connect", + apiKey: "accounts-test-key", + }); + expect(credRes.status).toBe("created"); + credGuid = (credRes.body as any).guid; + + const res = await client.getAccount("accounts-test-server"); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.name).toBe("accounts-test-server"); + expect(body.url).toBe("https://accounts-test.example.com"); + expect(body.type).toBe("connect"); + expect(body).toMatchSnapshot({ + type: expect.any(String), + source: expect.any(String), + authType: expect.any(String), + name: expect.any(String), + url: expect.any(String), + insecure: expect.any(Boolean), + caCert: expect.any(String), + accountName: expect.any(String), + }); + }); + + it("GET /api/accounts includes the created account in the list", async () => { + // This test depends on the credential created in the previous test + const res = await client.getAccounts(); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body.length).toBeGreaterThan(0); + + const account = body.find( + (a: any) => a.name === "accounts-test-server", + ); + expect(account).toBeDefined(); + }); + + it("GET /api/accounts/{name} returns 404 for non-existent account", async () => { + const res = await client.getAccount("nonexistent-account"); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/endpoints/config-files.test.ts b/test/api-contracts/src/endpoints/config-files.test.ts new file mode 100644 index 000000000..e006f2a27 --- /dev/null +++ b/test/api-contracts/src/endpoints/config-files.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient, seedConfigFile, removeConfigFile } from "../helpers"; + +const client = getClient(); + +describe("GET /api/configurations/{name}/files", () => { + it("returns file tree filtered by configuration", async () => { + const res = await client.getConfigFiles("test-config"); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("files"); + expect(body.isDir).toBe(true); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.getConfigFiles("nonexistent-config"); + expect(res.status).toBe("not_found"); + }); +}); + +describe("POST /api/configurations/{name}/files", () => { + const testName = "config-files-test"; + + afterAll(() => { + removeConfigFile(testName); + }); + + it("includes a file in the configuration", async () => { + // Create a config to modify + 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", + "fastapi-simple/requirements.txt", +] + +[python] +version = "3.11.3" +package_manager = "pip" +`, + ); + + const res = await client.postConfigFiles(testName, { + action: "include", + path: "static/index.html", + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + expect(body.configuration).toBeDefined(); + expect(body.configuration.files).toEqual( + expect.arrayContaining(["static/index.html"]), + ); + }); + + it("excludes a file from the configuration", async () => { + const res = await client.postConfigFiles(testName, { + action: "exclude", + path: "static/index.html", + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configuration.files).not.toEqual( + expect.arrayContaining(["static/index.html"]), + ); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.postConfigFiles("nonexistent-config", { + action: "include", + path: "foo.txt", + }); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/endpoints/config-packages.test.ts b/test/api-contracts/src/endpoints/config-packages.test.ts new file mode 100644 index 000000000..f7bb007e5 --- /dev/null +++ b/test/api-contracts/src/endpoints/config-packages.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient, seedConfigFile, removeConfigFile } from "../helpers"; + +const client = getClient(); + +describe("GET /api/configurations/{name}/packages/python", () => { + it("returns Python requirements for a Python config", async () => { + const res = await client.getConfigPythonPackages("test-config"); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body).toHaveProperty("requirements"); + expect(body.requirements).toBeInstanceOf(Array); + expect(body.requirements.length).toBeGreaterThan(0); + // test-config points to fastapi-simple which has fastapi and uvicorn + expect(body.requirements).toEqual( + expect.arrayContaining(["fastapi", "uvicorn"]), + ); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.getConfigPythonPackages("nonexistent-config"); + expect(res.status).toBe("not_found"); + }); +}); + +describe("GET /api/configurations/{name}/packages/r", () => { + it("returns 409 conflict for a config with no R section", async () => { + // test-config is a Python config with no R section + const res = await client.getConfigRPackages("test-config"); + expect(res.status).toBe("conflict"); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.getConfigRPackages("nonexistent-config"); + expect(res.status).toBe("not_found"); + }); + + describe("with an R configuration", () => { + const rConfigName = "r-packages-test"; + + afterAll(() => { + removeConfigFile(rConfigName); + }); + + it("returns R packages from renv.lock", async () => { + // Create a config with r-shiny type pointing to the rmd-static directory which has an renv.lock + seedConfigFile( + rConfigName, + `"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "r-shiny" +entrypoint = "rmd-static/report.Rmd" +files = [ + "rmd-static/report.Rmd", + "rmd-static/renv.lock", +] + +[r] +version = "4.3.1" +package_file = "rmd-static/renv.lock" +package_manager = "renv" +`, + ); + + const res = await client.getConfigRPackages(rConfigName); + expect(res.status).toBe("ok"); + + const body = res.body as any; + // The Go Lockfile struct uses lowercase JSON keys + expect(body).toHaveProperty("r"); + expect(body).toHaveProperty("packages"); + }); + }); +}); diff --git a/test/api-contracts/src/endpoints/config-secrets.test.ts b/test/api-contracts/src/endpoints/config-secrets.test.ts new file mode 100644 index 000000000..776678fb8 --- /dev/null +++ b/test/api-contracts/src/endpoints/config-secrets.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient, seedConfigFile, removeConfigFile } from "../helpers"; + +const client = getClient(); + +describe("GET /api/configurations/{name}/secrets", () => { + it("returns empty array for config with no secrets", async () => { + const res = await client.getConfigSecrets("test-config"); + expect(res.status).toBe("ok"); + + const body = res.body as string[]; + expect(body).toBeInstanceOf(Array); + expect(body).toEqual([]); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.getConfigSecrets("nonexistent-config"); + expect(res.status).toBe("not_found"); + }); +}); + +describe("POST /api/configurations/{name}/secrets", () => { + const testName = "secrets-test"; + + afterAll(() => { + removeConfigFile(testName); + }); + + it("adds a secret to the configuration", async () => { + 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", + "fastapi-simple/requirements.txt", +] + +[python] +version = "3.11.3" +package_manager = "pip" +`, + ); + + const res = await client.postConfigSecrets(testName, { + action: "add", + secret: "MY_SECRET", + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + expect(body.configuration).toBeDefined(); + expect(body.configuration.secrets).toEqual( + expect.arrayContaining(["MY_SECRET"]), + ); + }); + + it("lists the secret after adding it", async () => { + const res = await client.getConfigSecrets(testName); + expect(res.status).toBe("ok"); + + const body = res.body as string[]; + expect(body).toEqual(["MY_SECRET"]); + }); + + it("removes a secret from the configuration", async () => { + const res = await client.postConfigSecrets(testName, { + action: "remove", + secret: "MY_SECRET", + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + // After removing all secrets, the field may be omitted (undefined) or empty + const secrets = body.configuration.secrets; + expect(secrets === undefined || (Array.isArray(secrets) && secrets.length === 0)).toBe(true); + }); + + it("returns 404 for non-existent configuration", async () => { + const res = await client.postConfigSecrets("nonexistent-config", { + action: "add", + secret: "NOPE", + }); + expect(res.status).toBe("not_found"); + }); +}); 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/credential-by-guid.test.ts b/test/api-contracts/src/endpoints/credential-by-guid.test.ts new file mode 100644 index 000000000..619ac6b68 --- /dev/null +++ b/test/api-contracts/src/endpoints/credential-by-guid.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("GET /api/credentials/{guid}", () => { + it("returns a credential by GUID", async () => { + // First create a credential + const createRes = await client.postCredential({ + name: "guid-test-server", + url: "https://guid-test.example.com", + serverType: "connect", + apiKey: "guid-test-key-12345", + }); + expect(createRes.status).toBe("created"); + const createdGuid = (createRes.body as any).guid; + + // Fetch it by GUID + const res = await client.getCredential(createdGuid); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.guid).toBe(createdGuid); + expect(body.name).toBe("guid-test-server"); + expect(body.url).toBe("https://guid-test.example.com"); + expect(body).toMatchSnapshot({ + guid: expect.any(String), + name: expect.any(String), + url: expect.any(String), + serverType: expect.any(String), + apiKey: expect.any(String), + }); + + // Clean up immediately within the test + await client.deleteCredential(createdGuid); + }); + + it("returns 404 for non-existent GUID", async () => { + const res = await client.getCredential( + "00000000-0000-0000-0000-000000000000", + ); + 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/files.test.ts b/test/api-contracts/src/endpoints/files.test.ts new file mode 100644 index 000000000..5f013e77c --- /dev/null +++ b/test/api-contracts/src/endpoints/files.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("GET /api/files", () => { + it("returns file tree for the workspace root", async () => { + const res = await client.getFiles(); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("files"); + expect(body.isDir).toBe(true); + + // Should contain subdirectories from the fixture workspace + const childNames = (body.files as any[]).map((f: any) => f.base); + expect(childNames).toEqual( + expect.arrayContaining(["fastapi-simple", "static"]), + ); + }); + + it("returns subtree for a specific subdirectory", async () => { + const res = await client.getFiles({ pathname: "fastapi-simple" }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("files"); + expect(body.isDir).toBe(true); + expect(body.base).toBe("fastapi-simple"); + + const childNames = (body.files as any[]).map((f: any) => f.base); + expect(childNames).toEqual( + expect.arrayContaining(["app.py", "requirements.txt"]), + ); + }); +}); 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/endpoints/integration-requests.test.ts b/test/api-contracts/src/endpoints/integration-requests.test.ts new file mode 100644 index 000000000..c1a135292 --- /dev/null +++ b/test/api-contracts/src/endpoints/integration-requests.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getClient, seedConfigFile, removeConfigFile } from "../helpers"; + +const client = getClient(); + +describe("Integration Requests", () => { + const testName = "integration-req-test"; + + afterAll(() => { + removeConfigFile(testName); + }); + + it("GET returns null/empty initially", async () => { + 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", + "fastapi-simple/requirements.txt", +] + +[python] +version = "3.11.3" +package_manager = "pip" +`, + ); + + const res = await client.getIntegrationRequests(testName); + expect(res.status).toBe("ok"); + // Initially no integration requests — should be null or empty array + const body = res.body; + expect(body == null || (Array.isArray(body) && body.length === 0)).toBe( + true, + ); + }); + + it("POST adds an integration request", async () => { + const integrationRequest = { + guid: "test-integration-guid", + name: "snowflake-connection", + description: "Test Snowflake connection", + auth_type: "oauth2", + type: "snowflake", + config: { account: "test-account" }, + }; + + const res = await client.postIntegrationRequest( + testName, + integrationRequest, + ); + expect(res.status).toBe("created"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + expect(body.configuration).toBeDefined(); + }); + + it("GET returns the added integration request", async () => { + const res = await client.getIntegrationRequests(testName); + expect(res.status).toBe("ok"); + + const body = res.body as any[]; + expect(body).toBeInstanceOf(Array); + expect(body.length).toBeGreaterThan(0); + + const found = body.find( + (ir: any) => ir.guid === "test-integration-guid", + ); + expect(found).toBeDefined(); + expect(found.name).toBe("snowflake-connection"); + }); + + it("DELETE removes the integration request", async () => { + const res = await client.deleteIntegrationRequest(testName, { + guid: "test-integration-guid", + name: "snowflake-connection", + description: "Test Snowflake connection", + auth_type: "oauth2", + type: "snowflake", + config: { account: "test-account" }, + }); + expect(res.status).toBe("ok"); + + const body = res.body as any; + expect(body.configurationName).toBe(testName); + }); + + it("GET returns empty after deletion", async () => { + const res = await client.getIntegrationRequests(testName); + expect(res.status).toBe("ok"); + + const body = res.body; + expect(body == null || (Array.isArray(body) && body.length === 0)).toBe( + true, + ); + }); + + it("GET returns 404 for non-existent configuration", async () => { + const res = await client.getIntegrationRequests("nonexistent-config"); + expect(res.status).toBe("not_found"); + }); +}); diff --git a/test/api-contracts/src/endpoints/interpreters.test.ts b/test/api-contracts/src/endpoints/interpreters.test.ts new file mode 100644 index 000000000..8ef737f69 --- /dev/null +++ b/test/api-contracts/src/endpoints/interpreters.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { getClient } from "../helpers"; + +const client = getClient(); + +describe("GET /api/interpreters", () => { + it("returns interpreter information", async () => { + const res = await client.getInterpreters(); + expect(res.status).toBe("ok"); + + const body = res.body as any; + // The response should have python and/or r keys + // Depending on machine environment, one or both may be present + expect(body).toBeDefined(); + + if (body.python) { + expect(body.python).toHaveProperty("version"); + expect(body.python).toHaveProperty("packageManager"); + } + + if (body.r) { + expect(body.r).toHaveProperty("version"); + expect(body.r).toHaveProperty("packageManager"); + } + + // Snapshot with flexible matching for version-dependent fields + const matcher: Record = {}; + if (body.python) { + matcher.python = expect.objectContaining({ + version: expect.any(String), + packageManager: expect.any(String), + }); + } + if (body.preferredPythonPath !== undefined) { + matcher.preferredPythonPath = expect.any(String); + } + if (body.r) { + matcher.r = expect.objectContaining({ + version: expect.any(String), + packageManager: expect.any(String), + }); + } + if (body.preferredRPath !== undefined) { + matcher.preferredRPath = expect.any(String); + } + expect(body).toMatchSnapshot(matcher); + }); +}); 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/renv.lock b/test/api-contracts/src/fixtures/workspace/rmd-static/renv.lock new file mode 100644 index 000000000..9806217ac --- /dev/null +++ b/test/api-contracts/src/fixtures/workspace/rmd-static/renv.lock @@ -0,0 +1,25 @@ +{ + "R": { + "Version": "4.3.1", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "rmarkdown": { + "Package": "rmarkdown", + "Version": "2.25", + "Source": "Repository", + "Repository": "CRAN" + }, + "knitr": { + "Package": "knitr", + "Version": "1.45", + "Source": "Repository", + "Repository": "CRAN" + } + } +} 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..64949319b --- /dev/null +++ b/test/api-contracts/src/helpers.ts @@ -0,0 +1,123 @@ +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); + } +} + +/** + * Write an arbitrary file to the workspace at the given relative path. + */ +export function seedWorkspaceFile(relativePath: string, content: string): void { + const filePath = join(getWorkspaceDir(), relativePath); + const dir = join(filePath, ".."); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, content, "utf-8"); +} + +/** + * Remove an arbitrary file from the workspace. + */ +export function removeWorkspaceFile(relativePath: string): void { + const filePath = join(getWorkspaceDir(), relativePath); + if (existsSync(filePath)) { + rmSync(filePath); + } +} + +/** + * 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"], + }, +});