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

Hello from contract tests

+ + diff --git a/test/api-contracts/src/helpers.ts b/test/api-contracts/src/helpers.ts new file mode 100644 index 000000000..93693fc97 --- /dev/null +++ b/test/api-contracts/src/helpers.ts @@ -0,0 +1,103 @@ +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { BackendClient } from "./client"; +import { GoHttpClient } from "./clients/go-http-client"; +import { TypeScriptDirectClient } from "./clients/typescript-direct-client"; + +// --- Client accessor --- + +let _client: BackendClient | null = null; + +export function getClient(): BackendClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "go"; + if (clientType === "go") { + const base = process.env.API_BASE; + if (!base) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + _client = new GoHttpClient(base); + } else { + const dir = process.env.WORKSPACE_DIR; + if (!dir) { + throw new Error( + "WORKSPACE_DIR not set. Is the global setup running correctly?", + ); + } + _client = new TypeScriptDirectClient(dir); + } + return _client; +} + +export function getWorkspaceDir(): string { + const dir = process.env.WORKSPACE_DIR; + if (!dir) { + throw new Error( + "WORKSPACE_DIR not set. Is the global setup running correctly?", + ); + } + return dir; +} + +// --- Workspace manipulation --- + +function positPublishDir(): string { + return join(getWorkspaceDir(), ".posit", "publish"); +} + +function deploymentsDir(): string { + return join(positPublishDir(), "deployments"); +} + +/** + * Write a configuration TOML file to the workspace's .posit/publish/ directory. + */ +export function seedConfigFile(name: string, content: string): void { + const dir = positPublishDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.toml`), content, "utf-8"); +} + +/** + * Write a deployment TOML file to the workspace's .posit/publish/deployments/ directory. + */ +export function seedDeploymentFile(name: string, content: string): void { + const dir = deploymentsDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.toml`), content, "utf-8"); +} + +/** + * Remove a configuration TOML file from the workspace. + */ +export function removeConfigFile(name: string): void { + const path = join(positPublishDir(), `${name}.toml`); + if (existsSync(path)) { + rmSync(path); + } +} + +/** + * Remove a deployment TOML file from the workspace. + */ +export function removeDeploymentFile(name: string): void { + const path = join(deploymentsDir(), `${name}.toml`); + if (existsSync(path)) { + rmSync(path); + } +} + +/** + * Remove the entire .posit/publish directory and re-create it empty. + */ +export function resetDotPosit(): void { + const dir = positPublishDir(); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + mkdirSync(dir, { recursive: true }); + mkdirSync(deploymentsDir(), { recursive: true }); +} diff --git a/test/api-contracts/src/setup.ts b/test/api-contracts/src/setup.ts new file mode 100644 index 000000000..7a28775b2 --- /dev/null +++ b/test/api-contracts/src/setup.ts @@ -0,0 +1,152 @@ +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { GlobalSetupContext } from "vitest/node"; + +const REPO_ROOT = resolve(__dirname, "..", "..", ".."); +const FIXTURES_DIR = resolve(__dirname, "fixtures", "workspace"); + +let serverProcess: ChildProcess | null = null; +let tempDir: string | null = null; + +function getExecutablePath(): string { + const result = execSync("just executable-path", { + cwd: REPO_ROOT, + encoding: "utf-8", + }).trim(); + return resolve(REPO_ROOT, result); +} + +function waitForReady(apiBase: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Server did not become ready within ${timeoutMs}ms`)); + return; + } + try { + const res = await fetch(`${apiBase}/api/configurations`); + if (res.ok) { + resolve(); + return; + } + } catch { + // Not ready yet + } + setTimeout(poll, 200); + }; + poll(); + }); +} + +export async function setup({ provide }: GlobalSetupContext) { + // 1. Copy fixture workspace to temp directory + tempDir = mkdtempSync(join(tmpdir(), "publisher-contract-")); + cpSync(FIXTURES_DIR, tempDir, { recursive: true }); + process.env.WORKSPACE_DIR = tempDir; + + const backend = process.env.API_BACKEND ?? "go"; + + if (backend === "go") { + // 2. Find the Go binary + const binaryPath = getExecutablePath(); + + // 3. Spawn the server + serverProcess = spawn( + binaryPath, + ["ui", tempDir, "--listen", "localhost:0", "--use-keychain=false"], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Use a temp home directory so credential files don't pollute user's home + HOME: tempDir, + USERPROFILE: tempDir, + }, + }, + ); + + // 4. Capture the URL from stdout + const apiBase = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for server URL on stdout")); + }, 15_000); + + let buffer = ""; + serverProcess!.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("http://")) { + clearTimeout(timeout); + // Remove trailing slash + resolve(trimmed.replace(/\/$/, "")); + return; + } + } + }); + + serverProcess!.stderr!.on("data", (chunk: Buffer) => { + // Log stderr for debugging + process.stderr.write(`[publisher stderr] ${chunk.toString()}`); + }); + + serverProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn publisher: ${err.message}`)); + }); + + serverProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Publisher exited with code ${code}`)); + } + }); + }); + + // 5. Wait for the server to be ready + await waitForReady(apiBase); + + process.env.API_BASE = apiBase; + process.env.__CLIENT_TYPE = "go"; + + console.log(`[setup] Server running at ${apiBase}`); + } else { + // TypeScript backend — no subprocess needed + process.env.__CLIENT_TYPE = "typescript"; + + console.log(`[setup] Using TypeScript direct client`); + } + + console.log(`[setup] Workspace at ${tempDir}`); +} + +export async function teardown() { + // Kill the server process + if (serverProcess) { + serverProcess.kill("SIGTERM"); + // Wait briefly for graceful shutdown + await new Promise((resolve) => { + const timeout = setTimeout(() => { + serverProcess?.kill("SIGKILL"); + resolve(); + }, 5_000); + serverProcess!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + serverProcess = null; + } + + // Clean up temp directory + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + + console.log("[teardown] Server stopped and workspace cleaned up"); +} diff --git a/test/api-contracts/tsconfig.json b/test/api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/api-contracts/vitest.config.ts b/test/api-contracts/vitest.config.ts new file mode 100644 index 000000000..7713939bd --- /dev/null +++ b/test/api-contracts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + }, +}); From a537c9d2264a9e7f8748e82dc321673568ab19bd Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:18:13 -0500 Subject: [PATCH 2/2] Add API contract snapshots for files, config sub-resources, credentials, interpreters, and accounts Extends the contract test harness from 14 to ~28 endpoint coverage by adding tests for configuration files/secrets/packages/integration-requests, workspace files, credential-by-GUID, interpreters, and accounts endpoints. - Add 15 new methods to BackendClient interface and GoHttpClient implementation - Add TypeScriptDirectClient stubs for all new methods - Add "bad_request" (400) to ResultStatus and mapStatus - Improve toContractResult to handle missing Content-Type headers - Add seedWorkspaceFile/removeWorkspaceFile helpers - Add renv.lock fixture for R packages tests - Create 8 new test files with 31 new test cases Co-Authored-By: Claude Opus 4.6 --- test/api-contracts/src/client.ts | 27 ++- .../src/clients/go-http-client.ts | 185 +++++++++++++++++- .../src/clients/typescript-direct-client.ts | 97 +++++++++ .../__snapshots__/accounts.test.ts.snap | 14 ++ .../credential-by-guid.test.ts.snap | 19 ++ .../__snapshots__/inspect.test.ts.snap | 4 + .../__snapshots__/interpreters.test.ts.snap | 14 ++ .../src/endpoints/accounts.test.ts | 70 +++++++ .../src/endpoints/config-files.test.ts | 82 ++++++++ .../src/endpoints/config-packages.test.ts | 74 +++++++ .../src/endpoints/config-secrets.test.ts | 88 +++++++++ .../src/endpoints/credential-by-guid.test.ts | 44 +++++ .../api-contracts/src/endpoints/files.test.ts | 38 ++++ .../endpoints/integration-requests.test.ts | 104 ++++++++++ .../src/endpoints/interpreters.test.ts | 48 +++++ .../fixtures/workspace/rmd-static/renv.lock | 25 +++ test/api-contracts/src/helpers.ts | 20 ++ 17 files changed, 950 insertions(+), 3 deletions(-) create mode 100644 test/api-contracts/src/endpoints/__snapshots__/accounts.test.ts.snap create mode 100644 test/api-contracts/src/endpoints/__snapshots__/credential-by-guid.test.ts.snap create mode 100644 test/api-contracts/src/endpoints/__snapshots__/interpreters.test.ts.snap create mode 100644 test/api-contracts/src/endpoints/accounts.test.ts create mode 100644 test/api-contracts/src/endpoints/config-files.test.ts create mode 100644 test/api-contracts/src/endpoints/config-packages.test.ts create mode 100644 test/api-contracts/src/endpoints/config-secrets.test.ts create mode 100644 test/api-contracts/src/endpoints/credential-by-guid.test.ts create mode 100644 test/api-contracts/src/endpoints/files.test.ts create mode 100644 test/api-contracts/src/endpoints/integration-requests.test.ts create mode 100644 test/api-contracts/src/endpoints/interpreters.test.ts create mode 100644 test/api-contracts/src/fixtures/workspace/rmd-static/renv.lock diff --git a/test/api-contracts/src/client.ts b/test/api-contracts/src/client.ts index e87dda358..1482eb1ac 100644 --- a/test/api-contracts/src/client.ts +++ b/test/api-contracts/src/client.ts @@ -3,7 +3,8 @@ export type ResultStatus = | "created" | "no_content" | "not_found" - | "conflict"; + | "conflict" + | "bad_request"; export interface ContractResult { status: ResultStatus; @@ -39,4 +40,28 @@ export interface BackendClient { // 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 index 7eb7155af..9ef25a25e 100644 --- a/test/api-contracts/src/clients/go-http-client.ts +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -10,6 +10,8 @@ function mapStatus(httpStatus: number): ResultStatus { return "no_content"; case 404: return "not_found"; + case 400: + return "bad_request"; case 409: return "conflict"; default: @@ -21,8 +23,21 @@ async function toContractResult(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; let body: unknown = null; - if (res.status !== 204 && contentType.includes("application/json")) { - body = await res.json(); + 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 }; @@ -186,4 +201,170 @@ export class GoHttpClient implements BackendClient { ); 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 index 9f22e1303..1c49803c1 100644 --- a/test/api-contracts/src/clients/typescript-direct-client.ts +++ b/test/api-contracts/src/clients/typescript-direct-client.ts @@ -93,4 +93,101 @@ export class TypeScriptDirectClient implements BackendClient { ): 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__/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__/inspect.test.ts.snap b/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap index e86e9fb31..dd2de0cb7 100644 --- a/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap +++ b/test/api-contracts/src/endpoints/__snapshots__/inspect.test.ts.snap @@ -157,6 +157,7 @@ exports[`POST /api/inspect (per-directory) > inspects rmd-static/ — detects as "entrypoint": "report.Rmd", "files": [ "/report.Rmd", + "/renv.lock", ], "quarto": { "engines": [ @@ -182,6 +183,7 @@ exports[`POST /api/inspect (per-directory) > inspects rmd-static/ — detects as "entrypoint": "report.Rmd", "files": [ "/report.Rmd", + "/renv.lock", ], "r": {}, "title": "Report", @@ -383,6 +385,7 @@ exports[`POST /api/inspect (recursive) > inspects root workspace recursively — "entrypoint": "report.Rmd", "files": [ "/report.Rmd", + "/renv.lock", ], "quarto": { "engines": [ @@ -408,6 +411,7 @@ exports[`POST /api/inspect (recursive) > inspects root workspace recursively — "entrypoint": "report.Rmd", "files": [ "/report.Rmd", + "/renv.lock", ], "r": {}, "title": "Report", 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/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/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/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/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/helpers.ts b/test/api-contracts/src/helpers.ts index 93693fc97..64949319b 100644 --- a/test/api-contracts/src/helpers.ts +++ b/test/api-contracts/src/helpers.ts @@ -90,6 +90,26 @@ export function removeDeploymentFile(name: string): void { } } +/** + * 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. */