From e6a98e99b44c49eed079e8216518ca2dead08af6 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:53:31 -0500 Subject: [PATCH 1/8] Add API contract test harness for Go-to-TypeScript migration Introduce a Vitest-based test suite at test/api-contracts/ that spawns the real Go binary, makes HTTP requests to API endpoints, and validates response shapes via snapshot testing. This enables verifying API parity when migrating the backend from Go to TypeScript. Initial coverage includes 24 tests across 3 endpoint groups: - configurations (GET list, GET single, PUT create, DELETE) - credentials (GET list, POST create, DELETE by GUID, DELETE reset) - deployments (GET list, GET single, POST create, PATCH update, DELETE) Co-Authored-By: Claude Opus 4.6 --- justfile | 8 + test/api-contracts/package-lock.json | 1598 +++++++++++++++++ test/api-contracts/package.json | 14 + .../__snapshots__/configurations.test.ts.snap | 30 + .../__snapshots__/credentials.test.ts.snap | 19 + .../__snapshots__/deployments.test.ts.snap | 50 + .../src/endpoints/configurations.test.ts | 153 ++ .../src/endpoints/credentials.test.ts | 118 ++ .../src/endpoints/deployments.test.ts | 182 ++ .../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 + .../src/fixtures/workspace/static/index.html | 9 + test/api-contracts/src/helpers.ts | 124 ++ test/api-contracts/src/setup.ts | 143 ++ test/api-contracts/tsconfig.json | 14 + test/api-contracts/vitest.config.ts | 10 + 18 files changed, 2528 insertions(+) create mode 100644 test/api-contracts/package-lock.json create mode 100644 test/api-contracts/package.json 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/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/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/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/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/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/configurations.test.ts b/test/api-contracts/src/endpoints/configurations.test.ts new file mode 100644 index 000000000..fa2d46cac --- /dev/null +++ b/test/api-contracts/src/endpoints/configurations.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + apiGet, + apiPut, + apiDelete, + seedConfigFile, + removeConfigFile, +} from "../helpers"; + +describe("GET /api/configurations", () => { + it("returns configurations array with pre-seeded config", async () => { + const res = await apiGet("/api/configurations"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiGet("/api/configurations?dir=static"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("GET /api/configurations/{name}", () => { + it("returns a single configuration by name", async () => { + const res = await apiGet("/api/configurations/test-config"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiGet("/api/configurations/does-not-exist"); + expect(res.status).toBe(404); + }); +}); + +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 apiPut(`/api/configurations/${testName}`, newConfig); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiGet(`/api/configurations/${testName}`); + expect(res.status).toBe(200); + + const body = await res.json(); + 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 apiGet(`/api/configurations/${testName}`); + expect(getRes.status).toBe(200); + + // Delete it + const deleteRes = await apiDelete(`/api/configurations/${testName}`); + expect(deleteRes.status).toBe(204); + + // Verify it's gone + const afterRes = await apiGet(`/api/configurations/${testName}`); + expect(afterRes.status).toBe(404); + }); + + it("returns 404 when deleting non-existent configuration", async () => { + const res = await apiDelete("/api/configurations/does-not-exist"); + expect(res.status).toBe(404); + }); +}); 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..7d34ebc74 --- /dev/null +++ b/test/api-contracts/src/endpoints/credentials.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { apiGet, apiPost, apiDelete } from "../helpers"; + +describe("GET /api/credentials", () => { + it("returns credentials array (initially empty)", async () => { + const res = await apiGet("/api/credentials"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + expect(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 apiPost("/api/credentials", newCred); + expect(res.status).toBe(201); + expect(res.headers.get("content-type")).toContain("application/json"); + + const body = await res.json(); + 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 apiGet("/api/credentials"); + expect(res.status).toBe(200); + + const body = await res.json(); + 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 apiPost("/api/credentials", dupCred); + expect(res.status).toBe(409); + }); +}); + +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 apiPost("/api/credentials", cred); + expect(createRes.status).toBe(201); + const { guid } = await createRes.json(); + + // Delete it + const deleteRes = await apiDelete(`/api/credentials/${guid}`); + expect(deleteRes.status).toBe(204); + + // Verify it's gone from the list + const listRes = await apiGet("/api/credentials"); + const list = await listRes.json(); + 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 apiGet("/api/credentials"); + const before = await listBefore.json(); + if (before.length === 0) { + await apiPost("/api/credentials", { + name: "reset-test-server", + url: "https://reset-test.example.com", + serverType: "connect", + apiKey: "reset-test-key", + }); + } + + // Reset + const res = await apiDelete("/api/credentials"); + expect(res.status).toBe(200); + + // Verify empty + const listAfter = await apiGet("/api/credentials"); + const after = await listAfter.json(); + 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..250b1afbc --- /dev/null +++ b/test/api-contracts/src/endpoints/deployments.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + apiGet, + apiPost, + apiPatch, + apiDelete, + removeDeploymentFile, +} from "../helpers"; + +describe("GET /api/deployments", () => { + it("returns deployments array with pre-seeded deployment", async () => { + const res = await apiGet("/api/deployments"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiGet("/api/deployments?dir=static"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("GET /api/deployments/{name}", () => { + it("returns a single deployment by name", async () => { + const res = await apiGet("/api/deployments/test-deployment"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiGet("/api/deployments/does-not-exist"); + expect(res.status).toBe(404); + }); +}); + +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 apiPost("/api/credentials", { + name: credName, + url: "https://deploy-test.example.com", + serverType: "connect", + apiKey: "deploy-test-key", + }); + }); + + afterAll(async () => { + removeDeploymentFile(deployName); + // Clean up credential + const creds = await (await apiGet("/api/credentials")).json(); + const cred = creds.find((c: any) => c.name === credName); + if (cred) { + await apiDelete(`/api/credentials/${cred.guid}`); + } + }); + + it("creates a new deployment record", async () => { + const res = await apiPost("/api/deployments", { + account: credName, + config: "test-config", + saveName: deployName, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + 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 apiPost("/api/deployments", { + account: credName, + config: "test-config", + saveName: deployName, + }); + expect(res.status).toBe(409); + }); +}); + +describe("PATCH /api/deployments/{name}", () => { + it("updates configuration name on existing deployment", async () => { + const res = await apiPatch("/api/deployments/test-deployment", { + configurationName: "test-config", + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/json"); + + const body = await res.json(); + expect(body.configurationName).toBe("test-config"); + }); + + it("returns 404 for non-existent deployment", async () => { + const res = await apiPatch("/api/deployments/does-not-exist", { + configurationName: "test-config", + }); + expect(res.status).toBe(404); + }); +}); + +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 apiPost("/api/credentials", { + name: "delete-deploy-server", + url: "https://delete-deploy.example.com", + serverType: "connect", + apiKey: "delete-deploy-key", + }); + const cred = await credRes.json(); + + await apiPost("/api/deployments", { + account: "delete-deploy-server", + config: "test-config", + saveName: deleteName, + }); + + // Verify it exists + const getRes = await apiGet(`/api/deployments/${deleteName}`); + expect(getRes.status).toBe(200); + + // Delete it + const deleteRes = await apiDelete(`/api/deployments/${deleteName}`); + expect(deleteRes.status).toBe(204); + + // Verify it's gone + const afterRes = await apiGet(`/api/deployments/${deleteName}`); + expect(afterRes.status).toBe(404); + + // Clean up credential + await apiDelete(`/api/credentials/${cred.guid}`); + }); + + it("returns 404 when deleting non-existent deployment", async () => { + const res = await apiDelete("/api/deployments/does-not-exist"); + expect(res.status).toBe(404); + }); +}); 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/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..3ece0473d --- /dev/null +++ b/test/api-contracts/src/helpers.ts @@ -0,0 +1,124 @@ +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +export function getApiBase(): string { + const base = process.env.API_BASE; + if (!base) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + return base; +} + +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; +} + +// --- Fetch wrappers --- + +export async function apiGet(path: string): Promise { + return fetch(`${getApiBase()}${path}`); +} + +export async function apiPost( + path: string, + body?: unknown, +): Promise { + return fetch(`${getApiBase()}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +export async function apiPut(path: string, body?: unknown): Promise { + return fetch(`${getApiBase()}${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +export async function apiPatch( + path: string, + body?: unknown, +): Promise { + return fetch(`${getApiBase()}${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +export async function apiDelete(path: string): Promise { + return fetch(`${getApiBase()}${path}`, { + method: "DELETE", + }); +} + +// --- 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..a4bc63cb9 --- /dev/null +++ b/test/api-contracts/src/setup.ts @@ -0,0 +1,143 @@ +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 }); + + // 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); + + // 6. Provide the URL and temp dir to tests via env vars + // Vitest globalSetup `provide` is for typed injection, but we use env vars for simplicity + process.env.API_BASE = apiBase; + process.env.WORKSPACE_DIR = tempDir; + + console.log(`[setup] Server running at ${apiBase}`); + 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 e5fe7cbe2c759421161c0e03efddc53731757842 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:25:37 -0500 Subject: [PATCH 2/8] Decouple contract tests from HTTP transport via BackendClient interface Introduce a BackendClient abstraction so the same 24 contract tests and snapshots can validate both the Go HTTP backend and the future TypeScript direct-call backend. Tests now call client.getConfigurations() instead of apiGet("/api/configurations"), with backend selection via API_BACKEND env var. Co-Authored-By: Claude Opus 4.6 --- test/api-contracts/src/client.ts | 26 ++++ .../src/clients/go-http-client.ts | 140 ++++++++++++++++++ .../src/clients/typescript-direct-client.ts | 78 ++++++++++ .../src/endpoints/configurations.test.ts | 47 +++--- .../src/endpoints/credentials.test.ts | 45 +++--- .../src/endpoints/deployments.test.ts | 70 +++++---- test/api-contracts/src/helpers.ts | 79 ++++------ test/api-contracts/src/setup.ts | 123 ++++++++------- 8 files changed, 416 insertions(+), 192 deletions(-) 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 diff --git a/test/api-contracts/src/client.ts b/test/api-contracts/src/client.ts new file mode 100644 index 000000000..942b98908 --- /dev/null +++ b/test/api-contracts/src/client.ts @@ -0,0 +1,26 @@ +export interface ContractResult { + status: number; // 200, 201, 204, 404, 409, etc. + contentType: string; // "application/json" or "" + body: T; // parsed JSON body, or null for 204 +} + +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; +} 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..375b392e6 --- /dev/null +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -0,0 +1,140 @@ +import type { BackendClient, ContractResult } from "../client"; + +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: res.status, contentType, 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); + } +} 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..bae92f1e7 --- /dev/null +++ b/test/api-contracts/src/clients/typescript-direct-client.ts @@ -0,0 +1,78 @@ +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"); + } +} diff --git a/test/api-contracts/src/endpoints/configurations.test.ts b/test/api-contracts/src/endpoints/configurations.test.ts index fa2d46cac..5d2046353 100644 --- a/test/api-contracts/src/endpoints/configurations.test.ts +++ b/test/api-contracts/src/endpoints/configurations.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect, afterAll } from "vitest"; -import { - apiGet, - apiPut, - apiDelete, - seedConfigFile, - removeConfigFile, -} from "../helpers"; +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 apiGet("/api/configurations"); + const res = await client.getConfigurations(); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any[]; expect(body).toBeInstanceOf(Array); expect(body.length).toBeGreaterThan(0); @@ -37,20 +33,19 @@ describe("GET /api/configurations", () => { }); it("returns empty array for directory with no configs", async () => { - const res = await apiGet("/api/configurations?dir=static"); + const res = await client.getConfigurations({ dir: "static" }); expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toEqual([]); + expect(res.body).toEqual([]); }); }); describe("GET /api/configurations/{name}", () => { it("returns a single configuration by name", async () => { - const res = await apiGet("/api/configurations/test-config"); + const res = await client.getConfiguration("test-config"); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any; expect(body.configurationName).toBe("test-config"); expect(body.configuration).toBeDefined(); expect(body.configuration.type).toBe("python-fastapi"); @@ -69,7 +64,7 @@ describe("GET /api/configurations/{name}", () => { }); it("returns 404 for non-existent configuration", async () => { - const res = await apiGet("/api/configurations/does-not-exist"); + const res = await client.getConfiguration("does-not-exist"); expect(res.status).toBe(404); }); }); @@ -94,11 +89,11 @@ describe("PUT /api/configurations/{name}", () => { }, }; - const res = await apiPut(`/api/configurations/${testName}`, newConfig); + const res = await client.putConfiguration(testName, newConfig); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any; expect(body.configurationName).toBe(testName); expect(body.configuration).toBeDefined(); expect(body.configuration.type).toBe("python-fastapi"); @@ -106,10 +101,10 @@ describe("PUT /api/configurations/{name}", () => { }); it("can read back the created configuration", async () => { - const res = await apiGet(`/api/configurations/${testName}`); + const res = await client.getConfiguration(testName); expect(res.status).toBe(200); - const body = await res.json(); + const body = res.body as any; expect(body.configurationName).toBe(testName); expect(body.configuration.type).toBe("python-fastapi"); }); @@ -134,20 +129,20 @@ package_manager = "pip" ); // Verify it exists - const getRes = await apiGet(`/api/configurations/${testName}`); + const getRes = await client.getConfiguration(testName); expect(getRes.status).toBe(200); // Delete it - const deleteRes = await apiDelete(`/api/configurations/${testName}`); + const deleteRes = await client.deleteConfiguration(testName); expect(deleteRes.status).toBe(204); // Verify it's gone - const afterRes = await apiGet(`/api/configurations/${testName}`); + const afterRes = await client.getConfiguration(testName); expect(afterRes.status).toBe(404); }); it("returns 404 when deleting non-existent configuration", async () => { - const res = await apiDelete("/api/configurations/does-not-exist"); + const res = await client.deleteConfiguration("does-not-exist"); expect(res.status).toBe(404); }); }); diff --git a/test/api-contracts/src/endpoints/credentials.test.ts b/test/api-contracts/src/endpoints/credentials.test.ts index 7d34ebc74..e4572a37b 100644 --- a/test/api-contracts/src/endpoints/credentials.test.ts +++ b/test/api-contracts/src/endpoints/credentials.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect } from "vitest"; -import { apiGet, apiPost, apiDelete } from "../helpers"; +import { getClient } from "../helpers"; + +const client = getClient(); describe("GET /api/credentials", () => { it("returns credentials array (initially empty)", async () => { - const res = await apiGet("/api/credentials"); + const res = await client.getCredentials(); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); - expect(body).toBeInstanceOf(Array); + expect(res.body).toBeInstanceOf(Array); }); }); @@ -23,11 +24,11 @@ describe("POST /api/credentials", () => { apiKey: "test-api-key-12345", }; - const res = await apiPost("/api/credentials", newCred); + const res = await client.postCredential(newCred); expect(res.status).toBe(201); - expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.contentType).toContain("application/json"); - const body = await res.json(); + 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"); @@ -45,10 +46,10 @@ describe("POST /api/credentials", () => { }); it("credential appears in list after creation", async () => { - const res = await apiGet("/api/credentials"); + const res = await client.getCredentials(); expect(res.status).toBe(200); - const body = await res.json(); + 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"); @@ -62,7 +63,7 @@ describe("POST /api/credentials", () => { apiKey: "another-api-key", }; - const res = await apiPost("/api/credentials", dupCred); + const res = await client.postCredential(dupCred); expect(res.status).toBe(409); }); }); @@ -76,17 +77,17 @@ describe("DELETE /api/credentials/{guid}", () => { serverType: "connect", apiKey: "delete-me-key", }; - const createRes = await apiPost("/api/credentials", cred); + const createRes = await client.postCredential(cred); expect(createRes.status).toBe(201); - const { guid } = await createRes.json(); + const { guid } = createRes.body as any; // Delete it - const deleteRes = await apiDelete(`/api/credentials/${guid}`); + const deleteRes = await client.deleteCredential(guid); expect(deleteRes.status).toBe(204); // Verify it's gone from the list - const listRes = await apiGet("/api/credentials"); - const list = await listRes.json(); + const listRes = await client.getCredentials(); + const list = listRes.body as any[]; const found = list.find((c: any) => c.guid === guid); expect(found).toBeUndefined(); }); @@ -95,10 +96,10 @@ describe("DELETE /api/credentials/{guid}", () => { describe("DELETE /api/credentials (reset)", () => { it("resets all credentials", async () => { // Ensure we have at least one credential - const listBefore = await apiGet("/api/credentials"); - const before = await listBefore.json(); + const listBefore = await client.getCredentials(); + const before = listBefore.body as any[]; if (before.length === 0) { - await apiPost("/api/credentials", { + await client.postCredential({ name: "reset-test-server", url: "https://reset-test.example.com", serverType: "connect", @@ -107,12 +108,12 @@ describe("DELETE /api/credentials (reset)", () => { } // Reset - const res = await apiDelete("/api/credentials"); + const res = await client.resetCredentials(); expect(res.status).toBe(200); // Verify empty - const listAfter = await apiGet("/api/credentials"); - const after = await listAfter.json(); + 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 index 250b1afbc..8bbf1f642 100644 --- a/test/api-contracts/src/endpoints/deployments.test.ts +++ b/test/api-contracts/src/endpoints/deployments.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - apiGet, - apiPost, - apiPatch, - apiDelete, - removeDeploymentFile, -} from "../helpers"; +import { getClient, removeDeploymentFile } from "../helpers"; + +const client = getClient(); describe("GET /api/deployments", () => { it("returns deployments array with pre-seeded deployment", async () => { - const res = await apiGet("/api/deployments"); + const res = await client.getDeployments(); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any[]; expect(body).toBeInstanceOf(Array); expect(body.length).toBeGreaterThan(0); @@ -45,27 +41,26 @@ describe("GET /api/deployments", () => { }); it("returns empty array for directory with no deployments", async () => { - const res = await apiGet("/api/deployments?dir=static"); + const res = await client.getDeployments({ dir: "static" }); expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toEqual([]); + expect(res.body).toEqual([]); }); }); describe("GET /api/deployments/{name}", () => { it("returns a single deployment by name", async () => { - const res = await apiGet("/api/deployments/test-deployment"); + const res = await client.getDeployment("test-deployment"); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + 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 apiGet("/api/deployments/does-not-exist"); + const res = await client.getDeployment("does-not-exist"); expect(res.status).toBe(404); }); }); @@ -76,7 +71,7 @@ describe("POST /api/deployments (create deployment record)", () => { beforeAll(async () => { // Create a credential so we have an account to reference - await apiPost("/api/credentials", { + await client.postCredential({ name: credName, url: "https://deploy-test.example.com", serverType: "connect", @@ -87,23 +82,24 @@ describe("POST /api/deployments (create deployment record)", () => { afterAll(async () => { removeDeploymentFile(deployName); // Clean up credential - const creds = await (await apiGet("/api/credentials")).json(); - const cred = creds.find((c: any) => c.name === credName); + const creds = await client.getCredentials(); + const credList = creds.body as any[]; + const cred = credList.find((c: any) => c.name === credName); if (cred) { - await apiDelete(`/api/credentials/${cred.guid}`); + await client.deleteCredential(cred.guid); } }); it("creates a new deployment record", async () => { - const res = await apiPost("/api/deployments", { + const res = await client.postDeployment({ account: credName, config: "test-config", saveName: deployName, }); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any; expect(body.deploymentName).toBe(deployName); expect(body.state).toBe("new"); expect(body.configurationName).toBe("test-config"); @@ -111,7 +107,7 @@ describe("POST /api/deployments (create deployment record)", () => { }); it("returns 409 when deployment already exists", async () => { - const res = await apiPost("/api/deployments", { + const res = await client.postDeployment({ account: credName, config: "test-config", saveName: deployName, @@ -122,18 +118,18 @@ describe("POST /api/deployments (create deployment record)", () => { describe("PATCH /api/deployments/{name}", () => { it("updates configuration name on existing deployment", async () => { - const res = await apiPatch("/api/deployments/test-deployment", { + const res = await client.patchDeployment("test-deployment", { configurationName: "test-config", }); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.contentType).toBe("application/json"); - const body = await res.json(); + const body = res.body as any; expect(body.configurationName).toBe("test-config"); }); it("returns 404 for non-existent deployment", async () => { - const res = await apiPatch("/api/deployments/does-not-exist", { + const res = await client.patchDeployment("does-not-exist", { configurationName: "test-config", }); expect(res.status).toBe(404); @@ -145,38 +141,38 @@ describe("DELETE /api/deployments/{name}", () => { it("deletes an existing deployment", async () => { // First, create a credential and deployment to delete - const credRes = await apiPost("/api/credentials", { + const credRes = await client.postCredential({ name: "delete-deploy-server", url: "https://delete-deploy.example.com", serverType: "connect", apiKey: "delete-deploy-key", }); - const cred = await credRes.json(); + const cred = credRes.body as any; - await apiPost("/api/deployments", { + await client.postDeployment({ account: "delete-deploy-server", config: "test-config", saveName: deleteName, }); // Verify it exists - const getRes = await apiGet(`/api/deployments/${deleteName}`); + const getRes = await client.getDeployment(deleteName); expect(getRes.status).toBe(200); // Delete it - const deleteRes = await apiDelete(`/api/deployments/${deleteName}`); + const deleteRes = await client.deleteDeployment(deleteName); expect(deleteRes.status).toBe(204); // Verify it's gone - const afterRes = await apiGet(`/api/deployments/${deleteName}`); + const afterRes = await client.getDeployment(deleteName); expect(afterRes.status).toBe(404); // Clean up credential - await apiDelete(`/api/credentials/${cred.guid}`); + await client.deleteCredential(cred.guid); }); it("returns 404 when deleting non-existent deployment", async () => { - const res = await apiDelete("/api/deployments/does-not-exist"); + const res = await client.deleteDeployment("does-not-exist"); expect(res.status).toBe(404); }); }); diff --git a/test/api-contracts/src/helpers.ts b/test/api-contracts/src/helpers.ts index 3ece0473d..93693fc97 100644 --- a/test/api-contracts/src/helpers.ts +++ b/test/api-contracts/src/helpers.ts @@ -1,14 +1,35 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; - -export function getApiBase(): string { - const base = process.env.API_BASE; - if (!base) { - throw new Error( - "API_BASE not set. Is the global setup running correctly?", - ); +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 base; + return _client; } export function getWorkspaceDir(): string { @@ -21,48 +42,6 @@ export function getWorkspaceDir(): string { return dir; } -// --- Fetch wrappers --- - -export async function apiGet(path: string): Promise { - return fetch(`${getApiBase()}${path}`); -} - -export async function apiPost( - path: string, - body?: unknown, -): Promise { - return fetch(`${getApiBase()}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); -} - -export async function apiPut(path: string, body?: unknown): Promise { - return fetch(`${getApiBase()}${path}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); -} - -export async function apiPatch( - path: string, - body?: unknown, -): Promise { - return fetch(`${getApiBase()}${path}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); -} - -export async function apiDelete(path: string): Promise { - return fetch(`${getApiBase()}${path}`, { - method: "DELETE", - }); -} - // --- Workspace manipulation --- function positPublishDir(): string { diff --git a/test/api-contracts/src/setup.ts b/test/api-contracts/src/setup.ts index a4bc63cb9..7a28775b2 100644 --- a/test/api-contracts/src/setup.ts +++ b/test/api-contracts/src/setup.ts @@ -45,73 +45,82 @@ 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; - // 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, + 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; + ); + + // 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!.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("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}`)); - } + 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); + // 5. Wait for the server to be ready + await waitForReady(apiBase); - // 6. Provide the URL and temp dir to tests via env vars - // Vitest globalSetup `provide` is for typed injection, but we use env vars for simplicity - process.env.API_BASE = apiBase; - process.env.WORKSPACE_DIR = tempDir; + 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] Server running at ${apiBase}`); console.log(`[setup] Workspace at ${tempDir}`); } From a59e6ffa2e8237cdbc961177d23663008d851b20 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:52:09 -0500 Subject: [PATCH 3/8] Replace HTTP status codes and contentType with semantic ResultStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use "ok" | "created" | "no_content" | "not_found" | "conflict" instead of numeric HTTP codes. Drop contentType from ContractResult entirely — the Go client validates content-type internally. This makes the future TypeScript direct-call client simpler to implement since it won't need to produce HTTP-specific values. Co-Authored-By: Claude Opus 4.6 --- test/api-contracts/src/client.ts | 12 ++++++-- .../src/clients/go-http-client.ts | 21 ++++++++++++-- .../src/endpoints/configurations.test.ts | 23 +++++++-------- .../src/endpoints/credentials.test.ts | 16 +++++------ .../src/endpoints/deployments.test.ts | 28 ++++++++----------- 5 files changed, 57 insertions(+), 43 deletions(-) diff --git a/test/api-contracts/src/client.ts b/test/api-contracts/src/client.ts index 942b98908..0beff30c7 100644 --- a/test/api-contracts/src/client.ts +++ b/test/api-contracts/src/client.ts @@ -1,7 +1,13 @@ +export type ResultStatus = + | "ok" + | "created" + | "no_content" + | "not_found" + | "conflict"; + export interface ContractResult { - status: number; // 200, 201, 204, 404, 409, etc. - contentType: string; // "application/json" or "" - body: T; // parsed JSON body, or null for 204 + status: ResultStatus; + body: T; // parsed response body, or null for no_content } export interface BackendClient { diff --git a/test/api-contracts/src/clients/go-http-client.ts b/test/api-contracts/src/clients/go-http-client.ts index 375b392e6..0020498dd 100644 --- a/test/api-contracts/src/clients/go-http-client.ts +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -1,4 +1,21 @@ -import type { BackendClient, ContractResult } from "../client"; +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") ?? ""; @@ -8,7 +25,7 @@ async function toContractResult(res: Response): Promise { body = await res.json(); } - return { status: res.status, contentType, body }; + return { status: mapStatus(res.status), body }; } export class GoHttpClient implements BackendClient { diff --git a/test/api-contracts/src/endpoints/configurations.test.ts b/test/api-contracts/src/endpoints/configurations.test.ts index 5d2046353..08dd21c80 100644 --- a/test/api-contracts/src/endpoints/configurations.test.ts +++ b/test/api-contracts/src/endpoints/configurations.test.ts @@ -6,8 +6,7 @@ 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(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any[]; expect(body).toBeInstanceOf(Array); @@ -34,7 +33,7 @@ describe("GET /api/configurations", () => { it("returns empty array for directory with no configs", async () => { const res = await client.getConfigurations({ dir: "static" }); - expect(res.status).toBe(200); + expect(res.status).toBe("ok"); expect(res.body).toEqual([]); }); }); @@ -42,8 +41,7 @@ describe("GET /api/configurations", () => { describe("GET /api/configurations/{name}", () => { it("returns a single configuration by name", async () => { const res = await client.getConfiguration("test-config"); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.configurationName).toBe("test-config"); @@ -65,7 +63,7 @@ describe("GET /api/configurations/{name}", () => { it("returns 404 for non-existent configuration", async () => { const res = await client.getConfiguration("does-not-exist"); - expect(res.status).toBe(404); + expect(res.status).toBe("not_found"); }); }); @@ -90,8 +88,7 @@ describe("PUT /api/configurations/{name}", () => { }; const res = await client.putConfiguration(testName, newConfig); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.configurationName).toBe(testName); @@ -102,7 +99,7 @@ describe("PUT /api/configurations/{name}", () => { it("can read back the created configuration", async () => { const res = await client.getConfiguration(testName); - expect(res.status).toBe(200); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.configurationName).toBe(testName); @@ -130,19 +127,19 @@ package_manager = "pip" // Verify it exists const getRes = await client.getConfiguration(testName); - expect(getRes.status).toBe(200); + expect(getRes.status).toBe("ok"); // Delete it const deleteRes = await client.deleteConfiguration(testName); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe("no_content"); // Verify it's gone const afterRes = await client.getConfiguration(testName); - expect(afterRes.status).toBe(404); + 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(404); + 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 index e4572a37b..0076d0bd0 100644 --- a/test/api-contracts/src/endpoints/credentials.test.ts +++ b/test/api-contracts/src/endpoints/credentials.test.ts @@ -6,8 +6,7 @@ const client = getClient(); describe("GET /api/credentials", () => { it("returns credentials array (initially empty)", async () => { const res = await client.getCredentials(); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); expect(res.body).toBeInstanceOf(Array); }); @@ -25,8 +24,7 @@ describe("POST /api/credentials", () => { }; const res = await client.postCredential(newCred); - expect(res.status).toBe(201); - expect(res.contentType).toContain("application/json"); + expect(res.status).toBe("created"); const body = res.body as any; expect(body.guid).toBeDefined(); @@ -47,7 +45,7 @@ describe("POST /api/credentials", () => { it("credential appears in list after creation", async () => { const res = await client.getCredentials(); - expect(res.status).toBe(200); + expect(res.status).toBe("ok"); const body = res.body as any[]; const found = body.find((c: any) => c.guid === createdGuid); @@ -64,7 +62,7 @@ describe("POST /api/credentials", () => { }; const res = await client.postCredential(dupCred); - expect(res.status).toBe(409); + expect(res.status).toBe("conflict"); }); }); @@ -78,12 +76,12 @@ describe("DELETE /api/credentials/{guid}", () => { apiKey: "delete-me-key", }; const createRes = await client.postCredential(cred); - expect(createRes.status).toBe(201); + expect(createRes.status).toBe("created"); const { guid } = createRes.body as any; // Delete it const deleteRes = await client.deleteCredential(guid); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe("no_content"); // Verify it's gone from the list const listRes = await client.getCredentials(); @@ -109,7 +107,7 @@ describe("DELETE /api/credentials (reset)", () => { // Reset const res = await client.resetCredentials(); - expect(res.status).toBe(200); + expect(res.status).toBe("ok"); // Verify empty const listAfter = await client.getCredentials(); diff --git a/test/api-contracts/src/endpoints/deployments.test.ts b/test/api-contracts/src/endpoints/deployments.test.ts index 8bbf1f642..7de2601a6 100644 --- a/test/api-contracts/src/endpoints/deployments.test.ts +++ b/test/api-contracts/src/endpoints/deployments.test.ts @@ -6,8 +6,7 @@ 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(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any[]; expect(body).toBeInstanceOf(Array); @@ -42,7 +41,7 @@ describe("GET /api/deployments", () => { it("returns empty array for directory with no deployments", async () => { const res = await client.getDeployments({ dir: "static" }); - expect(res.status).toBe(200); + expect(res.status).toBe("ok"); expect(res.body).toEqual([]); }); }); @@ -50,8 +49,7 @@ describe("GET /api/deployments", () => { describe("GET /api/deployments/{name}", () => { it("returns a single deployment by name", async () => { const res = await client.getDeployment("test-deployment"); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.deploymentName).toBe("test-deployment"); @@ -61,7 +59,7 @@ describe("GET /api/deployments/{name}", () => { it("returns 404 for non-existent deployment", async () => { const res = await client.getDeployment("does-not-exist"); - expect(res.status).toBe(404); + expect(res.status).toBe("not_found"); }); }); @@ -96,8 +94,7 @@ describe("POST /api/deployments (create deployment record)", () => { config: "test-config", saveName: deployName, }); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.deploymentName).toBe(deployName); @@ -112,7 +109,7 @@ describe("POST /api/deployments (create deployment record)", () => { config: "test-config", saveName: deployName, }); - expect(res.status).toBe(409); + expect(res.status).toBe("conflict"); }); }); @@ -121,8 +118,7 @@ describe("PATCH /api/deployments/{name}", () => { const res = await client.patchDeployment("test-deployment", { configurationName: "test-config", }); - expect(res.status).toBe(200); - expect(res.contentType).toBe("application/json"); + expect(res.status).toBe("ok"); const body = res.body as any; expect(body.configurationName).toBe("test-config"); @@ -132,7 +128,7 @@ describe("PATCH /api/deployments/{name}", () => { const res = await client.patchDeployment("does-not-exist", { configurationName: "test-config", }); - expect(res.status).toBe(404); + expect(res.status).toBe("not_found"); }); }); @@ -157,15 +153,15 @@ describe("DELETE /api/deployments/{name}", () => { // Verify it exists const getRes = await client.getDeployment(deleteName); - expect(getRes.status).toBe(200); + expect(getRes.status).toBe("ok"); // Delete it const deleteRes = await client.deleteDeployment(deleteName); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe("no_content"); // Verify it's gone const afterRes = await client.getDeployment(deleteName); - expect(afterRes.status).toBe(404); + expect(afterRes.status).toBe("not_found"); // Clean up credential await client.deleteCredential(cred.guid); @@ -173,6 +169,6 @@ describe("DELETE /api/deployments/{name}", () => { it("returns 404 when deleting non-existent deployment", async () => { const res = await client.deleteDeployment("does-not-exist"); - expect(res.status).toBe(404); + expect(res.status).toBe("not_found"); }); }); From 4d70f6a0ebe6c4ed983fd0c0dc93c8f60fd2f78a Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:16:14 -0500 Subject: [PATCH 4/8] Add Connect API contract test harness for validating Connect client behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a test harness that validates both the outbound HTTP requests Publisher sends to Connect and the correctly parsed responses. This enables the future TS ConnectClient to be verified against the same contract. Architecture: test code → Publisher binary → mock Connect server (Node.js). The mock captures all incoming requests for assertion. Currently implements TestAuthentication end-to-end (8 tests); CreateDeployment and ContentDetails are stubbed as describe.skip since they aren't triggerable via standalone Publisher API endpoints. Co-Authored-By: Claude Opus 4.6 --- justfile | 8 + test/connect-api-contracts/package-lock.json | 1598 +++++++++++++++++ test/connect-api-contracts/package.json | 14 + test/connect-api-contracts/src/client.ts | 28 + .../src/clients/go-publisher-client.ts | 80 + .../src/clients/ts-direct-client.ts | 31 + .../src/endpoints/authentication.test.ts | 129 ++ .../src/endpoints/content-details.test.ts | 17 + .../src/endpoints/create-deployment.test.ts | 17 + .../connect-responses/content-create.json | 33 + .../connect-responses/content-details.json | 33 + .../src/fixtures/connect-responses/user.json | 13 + .../fixtures/workspace/fastapi-simple/app.py | 8 + .../workspace/fastapi-simple/requirements.txt | 2 + .../src/fixtures/workspace/static/index.html | 9 + test/connect-api-contracts/src/helpers.ts | 51 + .../src/mock-connect-server.ts | 146 ++ test/connect-api-contracts/src/setup.ts | 158 ++ test/connect-api-contracts/tsconfig.json | 14 + test/connect-api-contracts/vitest.config.ts | 10 + 20 files changed, 2399 insertions(+) create mode 100644 test/connect-api-contracts/package-lock.json create mode 100644 test/connect-api-contracts/package.json create mode 100644 test/connect-api-contracts/src/client.ts create mode 100644 test/connect-api-contracts/src/clients/go-publisher-client.ts create mode 100644 test/connect-api-contracts/src/clients/ts-direct-client.ts create mode 100644 test/connect-api-contracts/src/endpoints/authentication.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/content-details.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/create-deployment.test.ts create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/content-create.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/content-details.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/user.json create mode 100644 test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py create mode 100644 test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt create mode 100644 test/connect-api-contracts/src/fixtures/workspace/static/index.html create mode 100644 test/connect-api-contracts/src/helpers.ts create mode 100644 test/connect-api-contracts/src/mock-connect-server.ts create mode 100644 test/connect-api-contracts/src/setup.ts create mode 100644 test/connect-api-contracts/tsconfig.json create mode 100644 test/connect-api-contracts/vitest.config.ts diff --git a/justfile b/justfile index b7b2bdfa0..23e48220b 100644 --- a/justfile +++ b/justfile @@ -242,6 +242,14 @@ test-contracts: cd test/api-contracts && npx vitest run +# Run Connect API contract tests against the Go binary (requires `just build` first) +test-connect-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/connect-api-contracts && npx vitest run + # Execute Python script tests (licenses, prepare-release, etc.) test-scripts: #!/usr/bin/env bash diff --git a/test/connect-api-contracts/package-lock.json b/test/connect-api-contracts/package-lock.json new file mode 100644 index 000000000..da8363023 --- /dev/null +++ b/test/connect-api-contracts/package-lock.json @@ -0,0 +1,1598 @@ +{ + "name": "connect-api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-api-contracts", + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/connect-api-contracts/package.json b/test/connect-api-contracts/package.json new file mode 100644 index 000000000..1fec51887 --- /dev/null +++ b/test/connect-api-contracts/package.json @@ -0,0 +1,14 @@ +{ + "name": "connect-api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/connect-api-contracts/src/client.ts b/test/connect-api-contracts/src/client.ts new file mode 100644 index 000000000..3d6799e77 --- /dev/null +++ b/test/connect-api-contracts/src/client.ts @@ -0,0 +1,28 @@ +import type { CapturedRequest } from "./mock-connect-server"; + +export type ConnectContractStatus = "success" | "error"; + +export interface ConnectContractResult { + status: ConnectContractStatus; + result: T; + capturedRequest: CapturedRequest | null; +} + +export interface ConnectContractClient { + testAuthentication(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + createDeployment(params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise; + + contentDetails(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; +} diff --git a/test/connect-api-contracts/src/clients/go-publisher-client.ts b/test/connect-api-contracts/src/clients/go-publisher-client.ts new file mode 100644 index 000000000..5b18220fd --- /dev/null +++ b/test/connect-api-contracts/src/clients/go-publisher-client.ts @@ -0,0 +1,80 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; +import type { CapturedRequest } from "../mock-connect-server"; + +export class GoPublisherClient implements ConnectContractClient { + constructor(private apiBase: string) {} + + private getMockConnectUrl(): string { + const url = process.env.MOCK_CONNECT_URL; + if (!url) { + throw new Error("MOCK_CONNECT_URL not set"); + } + return url; + } + + private async clearMockRequests(): Promise { + const mockUrl = this.getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); + } + + private async getCapturedRequests( + pathFilter: string, + ): Promise { + const mockUrl = this.getMockConnectUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + return requests.filter((r) => r.path.includes(pathFilter)); + } + + async testAuthentication(params: { + connectUrl: string; + apiKey: string; + }): Promise { + // Clear any previously captured requests + await this.clearMockRequests(); + + // Call Publisher's POST /api/test-credentials endpoint + const res = await fetch(`${this.apiBase}/api/test-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: params.connectUrl, + apiKey: params.apiKey, + insecure: false, + timeout: 30, + }), + }); + + const body = await res.json(); + + // Fetch the captured request that Publisher sent to mock Connect + const captured = await this.getCapturedRequests("/__api__/v1/user"); + const capturedRequest = captured.length > 0 ? captured[0] : null; + + return { + status: body.error ? "error" : "success", + result: body, + capturedRequest, + }; + } + + async createDeployment(_params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers CreateDeployment", + ); + } + + async contentDetails(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers ContentDetails", + ); + } +} diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts new file mode 100644 index 000000000..a623942ef --- /dev/null +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -0,0 +1,31 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; + +/** + * Stub client for the future TypeScript ConnectClient implementation. + * Each method will call the TS client directly against the mock Connect server. + * For now, all methods throw "Not implemented yet". + */ +export class TypeScriptDirectClient implements ConnectContractClient { + async testAuthentication(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async createDeployment(_params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async contentDetails(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/connect-api-contracts/src/endpoints/authentication.test.ts b/test/connect-api-contracts/src/endpoints/authentication.test.ts new file mode 100644 index 000000000..14eda7c9c --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/authentication.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe("TestAuthentication", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses user fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + }; + + // Publisher maps Connect's UserDTO (12 fields) down to User (5 fields) + expect(body.user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + + it("returns serverType as connect", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { serverType: string }; + + expect(body.serverType).toBe("connect"); + }); + + it("returns the mock Connect URL", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { url: string }; + + expect(body.url).toBe(connectUrl); + }); + + it("returns null error on success", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { error: unknown }; + + expect(body.error).toBeNull(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + // Mask dynamic URL for snapshot stability + const body = result.result as Record; + const snapshot = { ...body, url: "{{MOCK_CONNECT_URL}}" }; + + expect(snapshot).toMatchInlineSnapshot(` + { + "error": null, + "serverType": "connect", + "url": "{{MOCK_CONNECT_URL}}", + "user": { + "email": "bob@example.com", + "first_name": "Bob", + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "last_name": "Bobberson", + "username": "bob", + }, + } + `); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/content-details.test.ts b/test/connect-api-contracts/src/endpoints/content-details.test.ts new file mode 100644 index 000000000..7308d778f --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/content-details.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from "vitest"; + +describe.skip("ContentDetails", () => { + it("sends GET to /__api__/v1/content/:id", () => { + // Cannot be tested via Go path — no standalone Publisher API endpoint + // triggers ContentDetails on Connect. Will be tested when the TS + // ConnectClient is implemented and can be called directly. + }); + + it("sends Authorization header with Key prefix", () => { + // Stubbed for future TS direct client + }); + + it("parses content fields from response", () => { + // Stubbed for future TS direct client + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/create-deployment.test.ts b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts new file mode 100644 index 000000000..2a472cd57 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from "vitest"; + +describe.skip("CreateDeployment", () => { + it("sends POST to /__api__/v1/content", () => { + // Cannot be tested via Go path — no standalone Publisher API endpoint + // triggers CreateDeployment on Connect. Will be tested when the TS + // ConnectClient is implemented and can be called directly. + }); + + it("sends Authorization header with Key prefix", () => { + // Stubbed for future TS direct client + }); + + it("parses content GUID from response", () => { + // Stubbed for future TS direct client + }); +}); diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json new file mode 100644 index 000000000..224697a03 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T10:30:00Z", + "bundle_id": null, + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json new file mode 100644 index 000000000..34e3b0284 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "A sample FastAPI application", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T12:00:00Z", + "bundle_id": "101", + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user.json b/test/connect-api-contracts/src/fixtures/connect-responses/user.json new file mode 100644 index 000000000..34778cc95 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py new file mode 100644 index 000000000..af0afc8cf --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"message": "Hello World"} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt new file mode 100644 index 000000000..97dc7cd8c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/test/connect-api-contracts/src/fixtures/workspace/static/index.html b/test/connect-api-contracts/src/fixtures/workspace/static/index.html new file mode 100644 index 000000000..05da19bbd --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/static/index.html @@ -0,0 +1,9 @@ + + + + Test Static Content + + +

Hello from contract tests

+ + diff --git a/test/connect-api-contracts/src/helpers.ts b/test/connect-api-contracts/src/helpers.ts new file mode 100644 index 000000000..38183c398 --- /dev/null +++ b/test/connect-api-contracts/src/helpers.ts @@ -0,0 +1,51 @@ +import type { ConnectContractClient } from "./client"; +import type { CapturedRequest } from "./mock-connect-server"; +import { GoPublisherClient } from "./clients/go-publisher-client"; +import { TypeScriptDirectClient } from "./clients/ts-direct-client"; + +let _client: ConnectContractClient | null = null; + +export function getClient(): ConnectContractClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "go"; + if (clientType === "go") { + const apiBase = process.env.API_BASE; + if (!apiBase) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + _client = new GoPublisherClient(apiBase); + } else { + _client = new TypeScriptDirectClient(); + } + return _client; +} + +export function getMockConnectUrl(): string { + const url = process.env.MOCK_CONNECT_URL; + if (!url) { + throw new Error( + "MOCK_CONNECT_URL not set. Is the global setup running correctly?", + ); + } + return url; +} + +export async function clearMockRequests(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); +} + +export async function getMockRequests( + pathFilter?: string, +): Promise { + const mockUrl = getMockConnectUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; +} diff --git a/test/connect-api-contracts/src/mock-connect-server.ts b/test/connect-api-contracts/src/mock-connect-server.ts new file mode 100644 index 000000000..76170316a --- /dev/null +++ b/test/connect-api-contracts/src/mock-connect-server.ts @@ -0,0 +1,146 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface CapturedRequest { + method: string; + path: string; + headers: Record; + body: string | null; +} + +interface RouteHandler { + method: string; + pattern: RegExp; + status: number; + response: unknown; +} + +const FIXTURES_DIR = resolve(__dirname, "fixtures", "connect-responses"); + +function loadFixture(name: string): unknown { + const content = readFileSync(resolve(FIXTURES_DIR, name), "utf-8"); + return JSON.parse(content); +} + +export class MockConnectServer { + private server: ReturnType | null = null; + private captured: CapturedRequest[] = []; + private routes: RouteHandler[] = []; + private _port = 0; + + constructor() { + this.registerDefaultRoutes(); + } + + get port(): number { + return this._port; + } + + get url(): string { + return `http://localhost:${this._port}`; + } + + private registerDefaultRoutes(): void { + // GET /__api__/v1/user — TestAuthentication + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/user$/, + status: 200, + response: loadFixture("user.json"), + }); + + // POST /__api__/v1/content — CreateDeployment + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content$/, + status: 200, + response: loadFixture("content-create.json"), + }); + + // GET /__api__/v1/content/:id — ContentDetails + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 200, + response: loadFixture("content-details.json"), + }); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.listen(0, "localhost", () => { + const addr = this.server!.address(); + if (addr && typeof addr === "object") { + this._port = addr.port; + } + resolve(); + }); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const method = req.method ?? "GET"; + const path = req.url ?? "/"; + + // Control endpoint: GET captured requests + if (method === "GET" && path === "/__test__/requests") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(this.captured)); + return; + } + + // Control endpoint: clear captured requests + if (method === "DELETE" && path === "/__test__/requests") { + this.captured = []; + res.writeHead(204); + res.end(); + return; + } + + // Collect request body, then capture and route + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const bodyStr = chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + + // Flatten headers to Record + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(", "); + } + } + + // Capture the request + this.captured.push({ method, path, headers, body: bodyStr }); + + // Find matching route + const route = this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); + + if (route) { + res.writeHead(route.status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(route.response)); + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + } +} diff --git a/test/connect-api-contracts/src/setup.ts b/test/connect-api-contracts/src/setup.ts new file mode 100644 index 000000000..d09777337 --- /dev/null +++ b/test/connect-api-contracts/src/setup.ts @@ -0,0 +1,158 @@ +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { GlobalSetupContext } from "vitest/node"; +import { MockConnectServer } from "./mock-connect-server"; + +const REPO_ROOT = resolve(__dirname, "..", "..", ".."); +const FIXTURES_DIR = resolve(__dirname, "fixtures", "workspace"); + +let serverProcess: ChildProcess | null = null; +let mockServer: MockConnectServer | null = null; +let tempDir: string | null = null; + +function getExecutablePath(): string { + const result = execSync("just executable-path", { + cwd: REPO_ROOT, + encoding: "utf-8", + }).trim(); + return resolve(REPO_ROOT, result); +} + +function waitForReady(apiBase: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Server did not become ready within ${timeoutMs}ms`)); + return; + } + try { + const res = await fetch(`${apiBase}/api/configurations`); + if (res.ok) { + resolve(); + return; + } + } catch { + // Not ready yet + } + setTimeout(poll, 200); + }; + poll(); + }); +} + +export async function setup({ provide }: GlobalSetupContext) { + // 1. Start mock Connect server + mockServer = new MockConnectServer(); + await mockServer.start(); + process.env.MOCK_CONNECT_URL = mockServer.url; + + console.log(`[setup] Mock Connect server running at ${mockServer.url}`); + + // 2. Copy fixture workspace to temp directory + tempDir = mkdtempSync(join(tmpdir(), "publisher-connect-contract-")); + cpSync(FIXTURES_DIR, tempDir, { recursive: true }); + process.env.WORKSPACE_DIR = tempDir; + + const backend = process.env.API_BACKEND ?? "go"; + + if (backend === "go") { + // 3. Find the Go binary + const binaryPath = getExecutablePath(); + + // 4. Spawn the Publisher server + serverProcess = spawn( + binaryPath, + ["ui", tempDir, "--listen", "localhost:0", "--use-keychain=false"], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: tempDir, + USERPROFILE: tempDir, + }, + }, + ); + + // 5. Capture the URL from stdout + const apiBase = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for server URL on stdout")); + }, 15_000); + + let buffer = ""; + serverProcess!.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("http://")) { + clearTimeout(timeout); + resolve(trimmed.replace(/\/$/, "")); + return; + } + } + }); + + serverProcess!.stderr!.on("data", (chunk: Buffer) => { + process.stderr.write(`[publisher stderr] ${chunk.toString()}`); + }); + + serverProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn publisher: ${err.message}`)); + }); + + serverProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Publisher exited with code ${code}`)); + } + }); + }); + + // 6. Wait for the server to be ready + await waitForReady(apiBase); + + process.env.API_BASE = apiBase; + process.env.__CLIENT_TYPE = "go"; + + console.log(`[setup] Publisher server running at ${apiBase}`); + } else { + process.env.__CLIENT_TYPE = "typescript"; + console.log(`[setup] Using TypeScript direct client`); + } + + console.log(`[setup] Workspace at ${tempDir}`); +} + +export async function teardown() { + if (serverProcess) { + serverProcess.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + serverProcess?.kill("SIGKILL"); + resolve(); + }, 5_000); + serverProcess!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + serverProcess = null; + } + + if (mockServer) { + await mockServer.stop(); + mockServer = null; + } + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + + console.log("[teardown] Servers stopped and workspace cleaned up"); +} diff --git a/test/connect-api-contracts/tsconfig.json b/test/connect-api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/connect-api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/connect-api-contracts/vitest.config.ts b/test/connect-api-contracts/vitest.config.ts new file mode 100644 index 000000000..7713939bd --- /dev/null +++ b/test/connect-api-contracts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + }, +}); From 71d5e95fc05ef6b998854809351691f4b6fee84d Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:22:07 -0500 Subject: [PATCH 5/8] Add READMEs for api-contracts and connect-api-contracts test suites Co-Authored-By: Claude Opus 4.6 --- test/api-contracts/README.md | 54 +++++++++++++++++++ test/connect-api-contracts/README.md | 80 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/api-contracts/README.md create mode 100644 test/connect-api-contracts/README.md 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/connect-api-contracts/README.md b/test/connect-api-contracts/README.md new file mode 100644 index 000000000..032485561 --- /dev/null +++ b/test/connect-api-contracts/README.md @@ -0,0 +1,80 @@ +# Connect API Contract Tests + +Contract tests that validate the HTTP requests Publisher sends **to Posit Connect** and how it parses Connect's responses. These ensure a future TypeScript ConnectClient produces identical behavior to the Go implementation. + +This is fundamentally different from the [Publisher API contract tests](../api-contracts/) — those test Publisher's own API surface, while these test Publisher's role as a **client** of Connect's API. + +## Architecture + +``` +Test code → Publisher binary (Go) → Mock Connect server (Node.js) + POST /api/test-credentials GET /__api__/v1/user + (Publisher's own API) (canned Connect response) +``` + +Two servers are involved: + +1. **Mock Connect server** — A Node.js HTTP server that simulates Connect's API endpoints with canned JSON responses and captures all incoming requests for assertion. +2. **Publisher binary** — The system under test, which makes outbound HTTP calls to the mock. + +The mock exposes control endpoints for tests: +- `GET /__test__/requests` — Read all captured requests +- `DELETE /__test__/requests` — Clear captured requests + +## What's tested + +| Endpoint | Status | Notes | +|----------|--------|-------| +| `TestAuthentication` (`GET /__api__/v1/user`) | Active (8 tests) | Triggered via `POST /api/test-credentials` on Publisher | +| `CreateDeployment` (`POST /__api__/v1/content`) | Skipped | No standalone Publisher API endpoint to trigger it | +| `ContentDetails` (`GET /__api__/v1/content/:id`) | Skipped | No standalone Publisher API endpoint to trigger it | + +Each test validates both: +- **Request correctness** — method, path, `Authorization: Key ` header +- **Response parsing** — Publisher correctly transforms Connect's DTO into its internal types + +## Client implementations + +| Client | Description | +|--------|-------------| +| `GoPublisherClient` | Calls Publisher's HTTP API, which internally calls mock Connect | +| `TypeScriptDirectClient` | Stub for future TS ConnectClient (all methods throw) | + +Set `API_BACKEND=typescript` to run against the TS client once implemented. + +## Running + +```bash +# Build the Go binary first +just build + +# Run Connect contract tests +just test-connect-contracts + +# Or directly +cd test/connect-api-contracts && npx vitest run + +# Update snapshots +cd test/connect-api-contracts && npx vitest run --update +``` + +## Adding tests + +1. Add a method to the `ConnectContractClient` interface in `src/client.ts` +2. Implement it in both `src/clients/go-publisher-client.ts` and `src/clients/ts-direct-client.ts` +3. Add a route handler in `src/mock-connect-server.ts` with a canned response fixture +4. Create a test file in `src/endpoints/` +5. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture files + +- `src/fixtures/connect-responses/` — Canned JSON responses for Connect API endpoints +- `src/fixtures/workspace/` — Minimal project for Publisher startup (copied from `test/api-contracts/`) + +## Future expansion + +When the TS ConnectClient is built: +1. Implement `ts-direct-client.ts` to call the TS client directly against the mock +2. Un-skip `create-deployment.test.ts` and `content-details.test.ts` +3. Add more Connect endpoints (UploadBundle, DeployBundle, SetEnvVars, etc.) +4. Both Go and TS paths validate against the same snapshots and request expectations From 246e74651268b80897bbf3cb5e892d268b7a5378 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:53:22 -0500 Subject: [PATCH 6/8] Expand Connect API contract tests to cover all 15 APIClient methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds mock routes, fixtures, and skipped test files for every method on the Connect APIClient interface. The mock server now supports non-JSON responses (HTML, raw bytes, 204 no-body) and has routes ordered by specificity. All 65 new tests are describe.skip — ready to un-skip when the TS ConnectClient is implemented. Co-Authored-By: Claude Opus 4.6 --- test/connect-api-contracts/README.md | 31 +++- test/connect-api-contracts/src/client.ts | 74 ++++++++ .../src/clients/go-publisher-client.ts | 125 ++++++++++++- .../src/clients/ts-direct-client.ts | 98 ++++++++++ .../src/endpoints/content-details.test.ts | 79 ++++++++- .../src/endpoints/create-deployment.test.ts | 91 +++++++++- .../src/endpoints/deploy-bundle.test.ts | 96 ++++++++++ .../src/endpoints/download-bundle.test.ts | 81 +++++++++ .../src/endpoints/get-current-user.test.ts | 68 +++++++ .../src/endpoints/get-env-vars.test.ts | 75 ++++++++ .../src/endpoints/get-integrations.test.ts | 72 ++++++++ .../src/endpoints/get-settings.test.ts | 140 +++++++++++++++ .../src/endpoints/latest-bundle-id.test.ts | 76 ++++++++ .../src/endpoints/set-env-vars.test.ts | 85 +++++++++ .../src/endpoints/update-deployment.test.ts | 82 +++++++++ .../src/endpoints/upload-bundle.test.ts | 101 +++++++++++ .../src/endpoints/validate-deployment.test.ts | 59 +++++++ .../src/endpoints/wait-for-task.test.ts | 91 ++++++++++ .../connect-responses/bundle-upload.json | 20 +++ .../fixtures/connect-responses/deploy.json | 3 + .../connect-responses/environment.json | 1 + .../connect-responses/integrations.json | 11 ++ .../server-settings-applications.json | 6 + .../server-settings-python.json | 7 + .../server-settings-quarto.json | 5 + .../connect-responses/server-settings-r.json | 5 + .../server-settings-scheduler.json | 24 +++ .../connect-responses/server-settings.json | 19 ++ .../connect-responses/task-finished.json | 9 + .../src/mock-connect-server.ts | 167 +++++++++++++++++- 30 files changed, 1767 insertions(+), 34 deletions(-) create mode 100644 test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/download-bundle.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/get-current-user.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/get-env-vars.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/get-integrations.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/get-settings.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/set-env-vars.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/update-deployment.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/upload-bundle.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/validate-deployment.test.ts create mode 100644 test/connect-api-contracts/src/endpoints/wait-for-task.test.ts create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/deploy.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/environment.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/integrations.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json diff --git a/test/connect-api-contracts/README.md b/test/connect-api-contracts/README.md index 032485561..e3f4d2bb8 100644 --- a/test/connect-api-contracts/README.md +++ b/test/connect-api-contracts/README.md @@ -23,11 +23,27 @@ The mock exposes control endpoints for tests: ## What's tested -| Endpoint | Status | Notes | -|----------|--------|-------| -| `TestAuthentication` (`GET /__api__/v1/user`) | Active (8 tests) | Triggered via `POST /api/test-credentials` on Publisher | -| `CreateDeployment` (`POST /__api__/v1/content`) | Skipped | No standalone Publisher API endpoint to trigger it | -| `ContentDetails` (`GET /__api__/v1/content/:id`) | Skipped | No standalone Publisher API endpoint to trigger it | +All 15 methods on the Go `APIClient` interface have corresponding test files, mock routes, and fixtures. Only `TestAuthentication` is currently active (triggerable via the Go path); all others are `describe.skip` until the TS ConnectClient is implemented. + +| Endpoint | Connect Path | Status | +|----------|-------------|--------| +| `TestAuthentication` | `GET /__api__/v1/user` | **Active** (8 tests) | +| `GetCurrentUser` | `GET /__api__/v1/user` | Skipped | +| `ContentDetails` | `GET /__api__/v1/content/:id` | Skipped | +| `CreateDeployment` | `POST /__api__/v1/content` | Skipped | +| `UpdateDeployment` | `PATCH /__api__/v1/content/:id` | Skipped | +| `GetEnvVars` | `GET /__api__/v1/content/:id/environment` | Skipped | +| `SetEnvVars` | `PATCH /__api__/v1/content/:id/environment` | Skipped | +| `UploadBundle` | `POST /__api__/v1/content/:id/bundles` | Skipped | +| `DeployBundle` | `POST /__api__/v1/content/:id/deploy` | Skipped | +| `WaitForTask` | `GET /__api__/v1/tasks/:id?first=N` | Skipped | +| `ValidateDeployment` | `GET /content/:id/` | Skipped | +| `GetIntegrations` | `GET /__api__/v1/oauth/integrations` | Skipped | +| `GetSettings` | 7 endpoints (see below) | Skipped | +| `LatestBundleID` | `GET /__api__/v1/content/:id` | Skipped | +| `DownloadBundle` | `GET /__api__/v1/content/:id/bundles/:bid/download` | Skipped | + +`GetSettings` calls 7 endpoints in sequence: `/__api__/v1/user`, `/__api__/server_settings`, `/__api__/server_settings/applications`, `/__api__/server_settings/scheduler[/{appMode}]`, `/__api__/v1/server_settings/python`, `/__api__/v1/server_settings/r`, `/__api__/v1/server_settings/quarto`. Each test validates both: - **Request correctness** — method, path, `Authorization: Key ` header @@ -75,6 +91,5 @@ cd test/connect-api-contracts && npx vitest run --update When the TS ConnectClient is built: 1. Implement `ts-direct-client.ts` to call the TS client directly against the mock -2. Un-skip `create-deployment.test.ts` and `content-details.test.ts` -3. Add more Connect endpoints (UploadBundle, DeployBundle, SetEnvVars, etc.) -4. Both Go and TS paths validate against the same snapshots and request expectations +2. Un-skip all test files — fixtures, mock routes, and test assertions are already in place +3. Both Go and TS paths validate against the same snapshots and request expectations diff --git a/test/connect-api-contracts/src/client.ts b/test/connect-api-contracts/src/client.ts index 3d6799e77..9ae61a33d 100644 --- a/test/connect-api-contracts/src/client.ts +++ b/test/connect-api-contracts/src/client.ts @@ -14,6 +14,11 @@ export interface ConnectContractClient { apiKey: string; }): Promise; + getCurrentUser(params: { + connectUrl: string; + apiKey: string; + }): Promise; + createDeployment(params: { connectUrl: string; apiKey: string; @@ -25,4 +30,73 @@ export interface ConnectContractClient { apiKey: string; contentId: string; }): Promise; + + updateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise; + + getEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + setEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise; + + uploadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise; + + deployBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; + + waitForTask(params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise; + + validateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + getIntegrations(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + getSettings(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + latestBundleId(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + downloadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; } diff --git a/test/connect-api-contracts/src/clients/go-publisher-client.ts b/test/connect-api-contracts/src/clients/go-publisher-client.ts index 5b18220fd..58ce0943f 100644 --- a/test/connect-api-contracts/src/clients/go-publisher-client.ts +++ b/test/connect-api-contracts/src/clients/go-publisher-client.ts @@ -30,10 +30,8 @@ export class GoPublisherClient implements ConnectContractClient { connectUrl: string; apiKey: string; }): Promise { - // Clear any previously captured requests await this.clearMockRequests(); - // Call Publisher's POST /api/test-credentials endpoint const res = await fetch(`${this.apiBase}/api/test-credentials`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -47,7 +45,6 @@ export class GoPublisherClient implements ConnectContractClient { const body = await res.json(); - // Fetch the captured request that Publisher sent to mock Connect const captured = await this.getCapturedRequests("/__api__/v1/user"); const capturedRequest = captured.length > 0 ? captured[0] : null; @@ -58,6 +55,15 @@ export class GoPublisherClient implements ConnectContractClient { }; } + async getCurrentUser(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetCurrentUser", + ); + } + async createDeployment(_params: { connectUrl: string; apiKey: string; @@ -77,4 +83,117 @@ export class GoPublisherClient implements ConnectContractClient { "Not implemented — no standalone Publisher API endpoint triggers ContentDetails", ); } + + async updateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers UpdateDeployment", + ); + } + + async getEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetEnvVars", + ); + } + + async setEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers SetEnvVars", + ); + } + + async uploadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers UploadBundle", + ); + } + + async deployBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers DeployBundle", + ); + } + + async waitForTask(_params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers WaitForTask", + ); + } + + async validateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers ValidateDeployment", + ); + } + + async getIntegrations(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetIntegrations", + ); + } + + async getSettings(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers GetSettings", + ); + } + + async latestBundleId(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers LatestBundleID", + ); + } + + async downloadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error( + "Not implemented — no standalone Publisher API endpoint triggers DownloadBundle", + ); + } } diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index a623942ef..2abe70aac 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -13,6 +13,13 @@ export class TypeScriptDirectClient implements ConnectContractClient { throw new Error("Not implemented yet"); } + async getCurrentUser(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + async createDeployment(_params: { connectUrl: string; apiKey: string; @@ -28,4 +35,95 @@ export class TypeScriptDirectClient implements ConnectContractClient { }): Promise { throw new Error("Not implemented yet"); } + + async updateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async setEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise { + throw new Error("Not implemented yet"); + } + + async uploadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise { + throw new Error("Not implemented yet"); + } + + async deployBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async waitForTask(_params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async validateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getIntegrations(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getSettings(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async latestBundleId(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async downloadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } } diff --git a/test/connect-api-contracts/src/endpoints/content-details.test.ts b/test/connect-api-contracts/src/endpoints/content-details.test.ts index 7308d778f..51789a08b 100644 --- a/test/connect-api-contracts/src/endpoints/content-details.test.ts +++ b/test/connect-api-contracts/src/endpoints/content-details.test.ts @@ -1,17 +1,78 @@ -import { describe, it } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; describe.skip("ContentDetails", () => { - it("sends GET to /__api__/v1/content/:id", () => { - // Cannot be tested via Go path — no standalone Publisher API endpoint - // triggers ContentDetails on Connect. Will be tested when the TS - // ConnectClient is implemented and can be called directly. + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); }); - it("sends Authorization header with Key prefix", () => { - // Stubbed for future TS direct client + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); }); - it("parses content fields from response", () => { - // Stubbed for future TS direct client + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses ConnectContent fields from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as Record; + + expect(body.guid).toBe(contentId); + expect(body.name).toBe("my-fastapi-app"); + expect(body.app_mode).toBe("python-fastapi"); + expect(body.py_version).toBe("3.11.6"); + }); }); }); diff --git a/test/connect-api-contracts/src/endpoints/create-deployment.test.ts b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts index 2a472cd57..b2b3d3560 100644 --- a/test/connect-api-contracts/src/endpoints/create-deployment.test.ts +++ b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts @@ -1,17 +1,90 @@ -import { describe, it } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; describe.skip("CreateDeployment", () => { - it("sends POST to /__api__/v1/content", () => { - // Cannot be tested via Go path — no standalone Publisher API endpoint - // triggers CreateDeployment on Connect. Will be tested when the TS - // ConnectClient is implemented and can be called directly. + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); }); - it("sends Authorization header with Key prefix", () => { - // Stubbed for future TS direct client + describe("request correctness", () => { + it("sends POST to /__api__/v1/content", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/content"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { name: "my-app", title: "My App" }; + const result = await client.createDeployment({ + connectUrl, + apiKey, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); }); - it("parses content GUID from response", () => { - // Stubbed for future TS direct client + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.status).toBe("success"); + }); + + it("parses content GUID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + const body = result.result as { contentId: string }; + + expect(body.contentId).toBe(contentId); + }); }); }); diff --git a/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts new file mode 100644 index 000000000..14e04a0e4 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("DeployBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "201"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/deploy", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/deploy`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends bundle_id in request body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual({ bundle_id: bundleId }); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses task ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const body = result.result as { taskId: string }; + + expect(body.taskId).toBe("task-abc123-def456"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/download-bundle.test.ts b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts new file mode 100644 index 000000000..6fc678ddb --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("DownloadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "101"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/bundles/:bid/download", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("returns raw bytes from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const data = result.result as Uint8Array; + + expect(data).toBeInstanceOf(Uint8Array); + expect(data.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-current-user.test.ts b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts new file mode 100644 index 000000000..0c6aff11e --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetCurrentUser", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses User fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + const user = result.result as { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + + expect(user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts new file mode 100644 index 000000000..3f92ea9ef --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses environment variable name list", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + const envVars = result.result as string[]; + + expect(envVars).toEqual(["DATABASE_URL", "SECRET_KEY", "API_TOKEN"]); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-integrations.test.ts b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts new file mode 100644 index 000000000..da4f695e3 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("GetIntegrations", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/oauth/integrations", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + "/__api__/v1/oauth/integrations", + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses Integration array with expected fields", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + const integrations = result.result as Array<{ + guid: string; + name: string; + description: string; + auth_type: string; + template: string; + config: Record; + created_time: string; + }>; + + expect(integrations).toBeInstanceOf(Array); + expect(integrations.length).toBe(1); + expect(integrations[0].guid).toBe( + "int-guid-1234-5678-abcd-ef0123456789", + ); + expect(integrations[0].name).toBe("My OAuth Integration"); + expect(integrations[0].auth_type).toBe("OAuth2"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-settings.test.ts b/test/connect-api-contracts/src/endpoints/get-settings.test.ts new file mode 100644 index 000000000..66317b902 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-settings.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + getMockRequests, +} from "../helpers"; + +describe.skip("GetSettings", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/v1/user"); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/server_settings"); + const generalReq = requests.find( + (r) => r.path === "/__api__/server_settings", + ); + expect(generalReq).toBeDefined(); + expect(generalReq!.method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/applications", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/server_settings/applications", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/scheduler", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/server_settings/scheduler", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/python", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/v1/server_settings/python", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/r", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests("/__api__/v1/server_settings/r"); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/quarto", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const requests = await getMockRequests( + "/__api__/v1/server_settings/quarto", + ); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].method).toBe("GET"); + }); + + it("sends Authorization header on all 7 requests", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await client.getSettings({ connectUrl, apiKey }); + + const allRequests = await getMockRequests(); + expect(allRequests.length).toBeGreaterThanOrEqual(7); + + for (const req of allRequests) { + expect(req.headers["authorization"]).toBe(`Key ${apiKey}`); + } + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getSettings({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses composite settings from all endpoints", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getSettings({ connectUrl, apiKey }); + const settings = result.result as Record; + + expect(settings).toBeDefined(); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts new file mode 100644 index 000000000..1eb275c82 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("LatestBundleID", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("extracts bundle_id from content DTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as { bundleId: string }; + + // content-details.json has bundle_id: "101" + expect(body.bundleId).toBe("101"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts new file mode 100644 index 000000000..1b03a6516 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("SetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends env vars as [{name, value}] array body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db", SECRET: "abc" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual( + expect.arrayContaining([ + { name: "DATABASE_URL", value: "postgres://localhost/db" }, + { name: "SECRET", value: "abc" }, + ]), + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/update-deployment.test.ts b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts new file mode 100644 index 000000000..4fd1d2921 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("UpdateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { title: "Updated Title", description: "New description" }; + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts new file mode 100644 index 000000000..328b418d6 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("UploadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/bundles", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends Content-Type application/gzip", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toBe( + "application/gzip", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.status).toBe("success"); + }); + + it("parses bundle ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + const body = result.result as { bundleId: string }; + + expect(body.bundleId).toBe("201"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts new file mode 100644 index 000000000..76b1fa290 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("ValidateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /content/:id/ (non-API path)", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe(`/content/${contentId}/`); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 200 response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts new file mode 100644 index 000000000..8d0bf7f1d --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; + +describe.skip("WaitForTask", () => { + const apiKey = "test-api-key-12345"; + const taskId = "task-abc123-def456"; + + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/tasks/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toMatch( + /^\/__api__\/v1\/tasks\/task-abc123-def456/, + ); + }); + + it("includes first query parameter for pagination", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.path).toContain("first="); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status when task finishes with code 0", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses task output lines", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + const task = result.result as { finished: boolean; output: string[] }; + + expect(task.finished).toBe(true); + expect(task.output).toBeInstanceOf(Array); + expect(task.output.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json new file mode 100644 index 000000000..4de212760 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json @@ -0,0 +1,20 @@ +{ + "id": "201", + "content_guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "created_time": "2023-06-15T11:00:00Z", + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "active": false, + "size": 4096, + "metadata": { + "source": null, + "source_repo": null, + "source_branch": null, + "source_commit": null, + "archive_md5": "d41d8cd98f00b204e9800998ecf8427e", + "archive_sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json new file mode 100644 index 000000000..a22a89fe8 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json @@ -0,0 +1,3 @@ +{ + "task_id": "task-abc123-def456" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/environment.json b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json new file mode 100644 index 000000000..8349928ac --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json @@ -0,0 +1 @@ +["DATABASE_URL", "SECRET_KEY", "API_TOKEN"] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json new file mode 100644 index 000000000..a03a88e08 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json @@ -0,0 +1,11 @@ +[ + { + "guid": "int-guid-1234-5678-abcd-ef0123456789", + "name": "My OAuth Integration", + "description": "OAuth integration for external service", + "auth_type": "OAuth2", + "template": "custom", + "config": {}, + "created_time": "2023-06-01T09:00:00Z" + } +] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json new file mode 100644 index 000000000..35dacd30a --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json @@ -0,0 +1,6 @@ +{ + "access_types": ["acl", "logged_in", "all"], + "run_as": "rstudio-connect", + "run_as_group": "", + "run_as_current_user": false +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json new file mode 100644 index 000000000..7b142a85e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json @@ -0,0 +1,7 @@ +{ + "installations": [ + { "version": "3.11.6", "cluster_name": "", "image_name": "" }, + { "version": "3.10.12", "cluster_name": "", "image_name": "" } + ], + "api_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json new file mode 100644 index 000000000..f0810535f --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "1.4.550", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json new file mode 100644 index 000000000..20c599785 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "4.3.1", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json new file mode 100644 index 000000000..338c9d74c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json @@ -0,0 +1,24 @@ +{ + "min_processes": 0, + "max_processes": 3, + "max_conns_per_process": 20, + "load_factor": 0.5, + "init_timeout": 60, + "idle_timeout": 120, + "min_processes_limit": 0, + "max_processes_limit": 20, + "connection_timeout": 5, + "read_timeout": 30, + "cpu_request": 0.0, + "max_cpu_request": 0.0, + "cpu_limit": 0.0, + "max_cpu_limit": 0.0, + "memory_request": 0, + "max_memory_request": 0, + "memory_limit": 0, + "max_memory_limit": 0, + "amd_gpu_limit": 0, + "max_amd_gpu_limit": 0, + "nvidia_gpu_limit": 0, + "max_nvidia_gpu_limit": 0 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json new file mode 100644 index 000000000..b3805df71 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json @@ -0,0 +1,19 @@ +{ + "license": { + "allow-apis": true, + "current-user-execution": false, + "enable-launcher": false, + "oauth-integrations": true + }, + "runtimes": ["python", "r"], + "git_enabled": true, + "git_available": true, + "execution_type": "native", + "enable_runtime_constraints": false, + "enable_image_management": false, + "default_image_selection_enabled": false, + "default_environment_management_selection": true, + "default_r_environment_management": true, + "default_py_environment_management": true, + "oauth_integrations_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json new file mode 100644 index 000000000..7ee8a80cc --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Launching application...", "Application successfully deployed"], + "result": null, + "finished": true, + "code": 0, + "error": "", + "last": 4 +} diff --git a/test/connect-api-contracts/src/mock-connect-server.ts b/test/connect-api-contracts/src/mock-connect-server.ts index 76170316a..d80b98b65 100644 --- a/test/connect-api-contracts/src/mock-connect-server.ts +++ b/test/connect-api-contracts/src/mock-connect-server.ts @@ -13,7 +13,8 @@ interface RouteHandler { method: string; pattern: RegExp; status: number; - response: unknown; + response: unknown; // JSON object, string, Buffer, or null (for no-body responses) + contentType?: string; // defaults to "application/json" } const FIXTURES_DIR = resolve(__dirname, "fixtures", "connect-responses"); @@ -23,6 +24,12 @@ function loadFixture(name: string): unknown { return JSON.parse(content); } +// Minimal valid gzip stream (empty gzip file) +const DUMMY_GZIP_BYTES = Buffer.from([ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + export class MockConnectServer { private server: ReturnType | null = null; private captured: CapturedRequest[] = []; @@ -42,7 +49,11 @@ export class MockConnectServer { } private registerDefaultRoutes(): void { - // GET /__api__/v1/user — TestAuthentication + // Routes are matched by first match, so more specific patterns must come first. + + // --- Authentication & User --- + + // GET /__api__/v1/user — TestAuthentication, GetCurrentUser this.routes.push({ method: "GET", pattern: /^\/__api__\/v1\/user$/, @@ -50,6 +61,59 @@ export class MockConnectServer { response: loadFixture("user.json"), }); + // --- OAuth Integrations --- + + // GET /__api__/v1/oauth/integrations — GetIntegrations + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/oauth\/integrations$/, + status: 200, + response: loadFixture("integrations.json"), + }); + + // --- Content (specific sub-resources first, then generic) --- + + // GET /__api__/v1/content/:id/bundles/:bid/download — DownloadBundle + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles\/[^/]+\/download$/, + status: 200, + response: DUMMY_GZIP_BYTES, + contentType: "application/gzip", + }); + + // POST /__api__/v1/content/:id/bundles — UploadBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles$/, + status: 200, + response: loadFixture("bundle-upload.json"), + }); + + // GET /__api__/v1/content/:id/environment — GetEnvVars + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 200, + response: loadFixture("environment.json"), + }); + + // PATCH /__api__/v1/content/:id/environment — SetEnvVars + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 204, + response: null, + }); + + // POST /__api__/v1/content/:id/deploy — DeployBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/deploy$/, + status: 200, + response: loadFixture("deploy.json"), + }); + // POST /__api__/v1/content — CreateDeployment this.routes.push({ method: "POST", @@ -58,13 +122,92 @@ export class MockConnectServer { response: loadFixture("content-create.json"), }); - // GET /__api__/v1/content/:id — ContentDetails + // PATCH /__api__/v1/content/:id — UpdateDeployment + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 204, + response: null, + }); + + // GET /__api__/v1/content/:id — ContentDetails, LatestBundleID this.routes.push({ method: "GET", pattern: /^\/__api__\/v1\/content\/[^/]+$/, status: 200, response: loadFixture("content-details.json"), }); + + // --- Tasks --- + + // GET /__api__/v1/tasks/:id — WaitForTask (always returns finished) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/tasks\/[^?]+/, + status: 200, + response: loadFixture("task-finished.json"), + }); + + // --- Server Settings --- + + // GET /__api__/server_settings/applications — GetSettings (applications) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/applications$/, + status: 200, + response: loadFixture("server-settings-applications.json"), + }); + + // GET /__api__/server_settings/scheduler[/{appMode}] — GetSettings (scheduler) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/scheduler/, + status: 200, + response: loadFixture("server-settings-scheduler.json"), + }); + + // GET /__api__/server_settings — GetSettings (general) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings$/, + status: 200, + response: loadFixture("server-settings.json"), + }); + + // GET /__api__/v1/server_settings/python — GetSettings (python) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/python$/, + status: 200, + response: loadFixture("server-settings-python.json"), + }); + + // GET /__api__/v1/server_settings/r — GetSettings (r) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/r$/, + status: 200, + response: loadFixture("server-settings-r.json"), + }); + + // GET /__api__/v1/server_settings/quarto — GetSettings (quarto) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/quarto$/, + status: 200, + response: loadFixture("server-settings-quarto.json"), + }); + + // --- Content Validation (non-API path) --- + + // GET /content/:id/ — ValidateDeployment + this.routes.push({ + method: "GET", + pattern: /^\/content\/[^/]+\/$/, + status: 200, + response: "OK", + contentType: "text/html", + }); } async start(): Promise { @@ -135,8 +278,22 @@ export class MockConnectServer { ); if (route) { - res.writeHead(route.status, { "Content-Type": "application/json" }); - res.end(JSON.stringify(route.response)); + const contentType = route.contentType ?? "application/json"; + + if (route.response === null || route.response === undefined) { + // No-body response (e.g. 204) + res.writeHead(route.status); + res.end(); + } else if (Buffer.isBuffer(route.response)) { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else if (typeof route.response === "string") { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(JSON.stringify(route.response)); + } } else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); From f970113bed45159c621ced8b6fd862d6b11a4676 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:11:37 -0500 Subject: [PATCH 7/8] Add extension API contract tests for validating client HTTP behavior Introduces a third contract test suite (test/extension-api-contracts/) that validates how the extension's Axios client constructs requests to Publisher's API and parses responses. Uses a mock Publisher server with canned responses and request capture, following the connect-api-contracts pattern. 79 tests across configurations, credentials, and deployments endpoints verify request correctness (method, path, query params, headers, body), response parsing, and response shape stability via snapshots. Also includes expanded connect-api-contracts test coverage for authentication edge cases, content details, validate deployment, and wait-for-task endpoints. Co-Authored-By: Claude Opus 4.6 --- justfile | 8 + .../src/endpoints/authentication.test.ts | 138 +- .../src/endpoints/content-details.test.ts | 74 +- .../src/endpoints/validate-deployment.test.ts | 54 +- .../src/endpoints/wait-for-task.test.ts | 43 +- .../fixtures/connect-responses/error-401.json | 4 + .../fixtures/connect-responses/error-403.json | 4 + .../fixtures/connect-responses/error-500.json | 4 + .../connect-responses/task-failed.json | 9 + .../connect-responses/user-locked.json | 13 + .../connect-responses/user-unconfirmed.json | 13 + .../connect-responses/user-viewer.json | 13 + test/connect-api-contracts/src/helpers.ts | 20 + .../src/mock-connect-server.ts | 40 +- test/extension-api-contracts/README.md | 99 + .../extension-api-contracts/package-lock.json | 1880 +++++++++++++++++ test/extension-api-contracts/package.json | 17 + test/extension-api-contracts/src/client.ts | 32 + .../src/clients/axios-extension-client.ts | 293 +++ .../src/clients/fetch-reference-client.ts | 74 + .../__snapshots__/configurations.test.ts.snap | 92 + .../__snapshots__/credentials.test.ts.snap | 61 + .../__snapshots__/deployments.test.ts.snap | 87 + .../src/endpoints/configurations.test.ts | 238 +++ .../src/endpoints/credentials.test.ts | 288 +++ .../src/endpoints/deployments.test.ts | 300 +++ .../configuration-created.json | 17 + .../configuration-single.json | 17 + .../configurations-list.json | 36 + .../credential-created.json | 7 + .../credential-single.json | 7 + .../publisher-responses/credentials-list.json | 16 + .../credentials-reset.json | 3 + .../deployment-created.json | 18 + .../deployment-patched.json | 18 + .../deployment-single.json | 18 + .../publisher-responses/deployments-list.json | 20 + .../publisher-responses/test-credentials.json | 12 + test/extension-api-contracts/src/helpers.ts | 46 + .../src/mock-publisher-server.ts | 282 +++ test/extension-api-contracts/src/setup.ts | 25 + test/extension-api-contracts/tsconfig.json | 14 + test/extension-api-contracts/vitest.config.ts | 11 + 43 files changed, 4457 insertions(+), 8 deletions(-) create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/error-401.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/error-403.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/error-500.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json create mode 100644 test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json create mode 100644 test/extension-api-contracts/README.md create mode 100644 test/extension-api-contracts/package-lock.json create mode 100644 test/extension-api-contracts/package.json create mode 100644 test/extension-api-contracts/src/client.ts create mode 100644 test/extension-api-contracts/src/clients/axios-extension-client.ts create mode 100644 test/extension-api-contracts/src/clients/fetch-reference-client.ts create mode 100644 test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap create mode 100644 test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap create mode 100644 test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap create mode 100644 test/extension-api-contracts/src/endpoints/configurations.test.ts create mode 100644 test/extension-api-contracts/src/endpoints/credentials.test.ts create mode 100644 test/extension-api-contracts/src/endpoints/deployments.test.ts create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json create mode 100644 test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json create mode 100644 test/extension-api-contracts/src/helpers.ts create mode 100644 test/extension-api-contracts/src/mock-publisher-server.ts create mode 100644 test/extension-api-contracts/src/setup.ts create mode 100644 test/extension-api-contracts/tsconfig.json create mode 100644 test/extension-api-contracts/vitest.config.ts diff --git a/justfile b/justfile index 23e48220b..f4139868c 100644 --- a/justfile +++ b/justfile @@ -250,6 +250,14 @@ test-connect-contracts: cd test/connect-api-contracts && npx vitest run +# Run extension API contract tests (no Go binary needed — uses mock server) +test-extension-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/extension-api-contracts && npx vitest run + # Execute Python script tests (licenses, prepare-release, etc.) test-scripts: #!/usr/bin/env bash diff --git a/test/connect-api-contracts/src/endpoints/authentication.test.ts b/test/connect-api-contracts/src/endpoints/authentication.test.ts index 14eda7c9c..62bce6c79 100644 --- a/test/connect-api-contracts/src/endpoints/authentication.test.ts +++ b/test/connect-api-contracts/src/endpoints/authentication.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; describe("TestAuthentication", () => { const apiKey = "test-api-key-12345"; beforeEach(async () => { + await clearMockOverrides(); await clearMockRequests(); }); @@ -99,6 +106,135 @@ describe("TestAuthentication", () => { }); }); + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.user).toBeNull(); + }); + + it("returns error for locked user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: true, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("locked"); + expect(body.user).toBeNull(); + }); + + it("returns error for unconfirmed user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: false, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("not confirmed"); + expect(body.user).toBeNull(); + }); + + it("returns error for viewer role user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "viewer", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("permission"); + expect(body.user).toBeNull(); + }); + }); + describe("snapshot", () => { it("matches expected response shape", async () => { const client = getClient(); diff --git a/test/connect-api-contracts/src/endpoints/content-details.test.ts b/test/connect-api-contracts/src/endpoints/content-details.test.ts index 51789a08b..953cbd4ea 100644 --- a/test/connect-api-contracts/src/endpoints/content-details.test.ts +++ b/test/connect-api-contracts/src/endpoints/content-details.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; describe.skip("ContentDetails", () => { const apiKey = "test-api-key-12345"; const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; beforeEach(async () => { + await clearMockOverrides(); await clearMockRequests(); }); @@ -75,4 +82,69 @@ describe.skip("ContentDetails", () => { expect(body.py_version).toBe("3.11.6"); }); }); + + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 403 forbidden response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 403, + body: { + code: 4, + error: "You do not have permission to perform this operation", + }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 404 not found response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 404, + body: { code: 4, error: "Content not found" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + }); }); diff --git a/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts index 76b1fa290..64c17b342 100644 --- a/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts +++ b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; describe.skip("ValidateDeployment", () => { const apiKey = "test-api-key-12345"; const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; beforeEach(async () => { + await clearMockOverrides(); await clearMockRequests(); }); @@ -56,4 +63,49 @@ describe.skip("ValidateDeployment", () => { expect(result.status).toBe("success"); }); }); + + describe("error handling", () => { + it("returns error when content responds with 500", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 500, + body: "Internal Server Error", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns success when content responds with 404", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 404, + body: "Not Found", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + // 404 is acceptable — content may not be running yet + expect(result.status).toBe("success"); + }); + }); }); diff --git a/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts index 8d0bf7f1d..3e2a4c707 100644 --- a/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts +++ b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { getClient, getMockConnectUrl, clearMockRequests } from "../helpers"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; describe.skip("WaitForTask", () => { const apiKey = "test-api-key-12345"; const taskId = "task-abc123-def456"; beforeEach(async () => { + await clearMockOverrides(); await clearMockRequests(); }); @@ -88,4 +95,38 @@ describe.skip("WaitForTask", () => { expect(task.output.length).toBeGreaterThan(0); }); }); + + describe("error handling", () => { + it("returns error when task finishes with non-zero exit code", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/tasks/", + status: 200, + body: { + id: "task-abc123-def456", + output: [ + "Building Python application...", + "Bundle requested Python version 3.11.6", + "Error code: python-package-install-failed", + ], + result: null, + finished: true, + code: 1, + error: "Error code: python-package-install-failed", + last: 3, + }, + }); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("error"); + }); + }); }); diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json new file mode 100644 index 000000000..0d18c6161 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json @@ -0,0 +1,4 @@ +{ + "code": 3, + "error": "Key is not valid" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json new file mode 100644 index 000000000..98317777e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json @@ -0,0 +1,4 @@ +{ + "code": 4, + "error": "You do not have permission to perform this operation" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json new file mode 100644 index 000000000..ec5e0670c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json @@ -0,0 +1,4 @@ +{ + "code": 0, + "error": "Internal server error" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json new file mode 100644 index 000000000..93e10f41d --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Error code: python-package-install-failed"], + "result": null, + "finished": true, + "code": 1, + "error": "Error code: python-package-install-failed", + "last": 3 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json new file mode 100644 index 000000000..40bfb0b2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": true, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json new file mode 100644 index 000000000..23c46708e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": false, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json new file mode 100644 index 000000000..a72b0fc2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "viewer", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/helpers.ts b/test/connect-api-contracts/src/helpers.ts index 38183c398..c8db0388d 100644 --- a/test/connect-api-contracts/src/helpers.ts +++ b/test/connect-api-contracts/src/helpers.ts @@ -38,6 +38,26 @@ export async function clearMockRequests(): Promise { await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); } +export async function setMockResponse(override: { + method: string; + pathPattern: string; + status: number; + body?: unknown; + contentType?: string; +}): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-override`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(override), + }); +} + +export async function clearMockOverrides(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-overrides`, { method: "DELETE" }); +} + export async function getMockRequests( pathFilter?: string, ): Promise { diff --git a/test/connect-api-contracts/src/mock-connect-server.ts b/test/connect-api-contracts/src/mock-connect-server.ts index d80b98b65..44d05c472 100644 --- a/test/connect-api-contracts/src/mock-connect-server.ts +++ b/test/connect-api-contracts/src/mock-connect-server.ts @@ -34,6 +34,7 @@ export class MockConnectServer { private server: ReturnType | null = null; private captured: CapturedRequest[] = []; private routes: RouteHandler[] = []; + private overrides: RouteHandler[] = []; private _port = 0; constructor() { @@ -253,6 +254,33 @@ export class MockConnectServer { return; } + // Control endpoint: register a response override + if (method === "POST" && path === "/__test__/response-override") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + this.overrides.push({ + method: body.method, + pattern: new RegExp(body.pathPattern), + status: body.status, + response: body.body ?? null, + contentType: body.contentType, + }); + res.writeHead(204); + res.end(); + }); + return; + } + + // Control endpoint: clear all response overrides + if (method === "DELETE" && path === "/__test__/response-overrides") { + this.overrides = []; + res.writeHead(204); + res.end(); + return; + } + // Collect request body, then capture and route const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); @@ -272,10 +300,14 @@ export class MockConnectServer { // Capture the request this.captured.push({ method, path, headers, body: bodyStr }); - // Find matching route - const route = this.routes.find( - (r) => r.method === method && r.pattern.test(path), - ); + // Find matching route (overrides take priority over default routes) + const route = + this.overrides.find( + (r) => r.method === method && r.pattern.test(path), + ) ?? + this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); if (route) { const contentType = route.contentType ?? "application/json"; diff --git a/test/extension-api-contracts/README.md b/test/extension-api-contracts/README.md new file mode 100644 index 000000000..02e7e3820 --- /dev/null +++ b/test/extension-api-contracts/README.md @@ -0,0 +1,99 @@ +# Extension API Contract Tests + +Contract tests that validate how the **extension's HTTP client** constructs requests to Publisher's API and parses responses. While the [Publisher API contract tests](../api-contracts/) validate the server's behavior with a real Go binary, these tests validate the **client side** — ensuring the Axios-based extension correctly builds URLs, query params, headers, and request bodies. + +This is the complement of the [Publisher API contract tests](../api-contracts/), not a replacement. Together they form a complete contract: one side verifies the server, the other verifies the client. + +## Architecture + +``` +Test code → Axios client (mirrors extension) → Mock Publisher server (Node.js) + GET /configurations?dir=... canned JSON response + (same paths, params, body shapes) (matches Go backend shapes) +``` + +A single mock server is involved: + +1. **Mock Publisher server** — A Node.js HTTP server that returns canned JSON responses matching the shapes the Go backend produces, and captures all incoming requests for assertion. + +The mock exposes control endpoints for tests: +- `GET /__test__/requests` — Read all captured requests +- `DELETE /__test__/requests` — Clear captured requests + +Unlike the other contract test suites, no Go binary or temp workspace is needed — the mock server handles everything. + +## What's tested + +The Axios client mirrors the exact HTTP calls made by the extension's resource classes (`extensions/vscode/src/api/resources/`): + +| Client Method | Extension Class | HTTP Request | +|--------------|----------------|--------------| +| `getConfigurations` | `Configurations.getAll` | `GET /configurations?dir=...` | +| `getConfiguration` | `Configurations.get` | `GET /configurations/:name?dir=...` | +| `createOrUpdateConfiguration` | `Configurations.createOrUpdate` | `PUT /configurations/:name?dir=...` | +| `deleteConfiguration` | `Configurations.delete` | `DELETE /configurations/:name?dir=...` | +| `listCredentials` | `Credentials.list` | `GET /credentials` | +| `createCredential` | `Credentials.connectCreate` | `POST /credentials` | +| `getCredential` | `Credentials.get` | `GET /credentials/:guid` | +| `deleteCredential` | `Credentials.delete` | `DELETE /credentials/:guid` | +| `resetCredentials` | `Credentials.reset` | `DELETE /credentials` | +| `testCredentials` | `Credentials.test` | `POST /test-credentials` | +| `getDeployments` | `ContentRecords.getAll` | `GET /deployments?dir=...` | +| `getDeployment` | `ContentRecords.get` | `GET /deployments/:id?dir=...` | +| `createDeployment` | `ContentRecords.createNew` | `POST /deployments?dir=...` | +| `deleteDeployment` | `ContentRecords.delete` | `DELETE /deployments/:name?dir=...` | +| `patchDeployment` | `ContentRecords.patch` | `PATCH /deployments/:name?dir=...` | + +Each test validates: +- **Request correctness** — method, path, query params, headers, body +- **Response parsing** — client returns correctly shaped data with correct status +- **Snapshot** — response shape stability + +## URL path note + +The extension resource classes have an inconsistency: some use leading `/` (e.g., `"/configurations"`) and others don't (e.g., `"credentials"`). With Axios, a leading `/` resolves relative to the origin, bypassing any path component in `baseURL`. The mock server registers routes without any `/api` prefix to match the actual resolved paths. + +## Client implementations + +| Client | Description | +|--------|-------------| +| `AxiosExtensionClient` | Axios-based client mirroring the real extension's HTTP calls | +| `FetchReferenceClient` | Stub for future Positron/fetch-based client (all methods throw) | + +Set `API_BACKEND=fetch` to run against the fetch client once implemented. + +## Running + +```bash +# Install dependencies +cd test/extension-api-contracts && npm install + +# Run tests +just test-extension-contracts + +# Or directly +cd test/extension-api-contracts && npx vitest run + +# Update snapshots +cd test/extension-api-contracts && npx vitest run --update + +# Watch mode +cd test/extension-api-contracts && npx vitest +``` + +## Adding tests + +1. Add a method to the `ExtensionContractClient` interface in `src/client.ts` +2. Implement it in both `src/clients/axios-extension-client.ts` and `src/clients/fetch-reference-client.ts` +3. Add a route handler in `src/mock-publisher-server.ts` with a canned response fixture +4. Create or update a test file in `src/endpoints/` +5. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture files + +- `src/fixtures/publisher-responses/` — Canned JSON responses matching the shapes the Go backend returns + +## Related test suites + +- [Publisher API contract tests](../api-contracts/) — Tests Publisher's server side (real Go binary + raw fetch client) +- [Connect API contract tests](../connect-api-contracts/) — Tests Publisher as a client of Connect (Go binary + mock Connect server) diff --git a/test/extension-api-contracts/package-lock.json b/test/extension-api-contracts/package-lock.json new file mode 100644 index 000000000..556cf1514 --- /dev/null +++ b/test/extension-api-contracts/package-lock.json @@ -0,0 +1,1880 @@ +{ + "name": "extension-api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "extension-api-contracts", + "dependencies": { + "axios": "^1.7.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/extension-api-contracts/package.json b/test/extension-api-contracts/package.json new file mode 100644 index 000000000..618c2098b --- /dev/null +++ b/test/extension-api-contracts/package.json @@ -0,0 +1,17 @@ +{ + "name": "extension-api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "dependencies": { + "axios": "^1.7.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/extension-api-contracts/src/client.ts b/test/extension-api-contracts/src/client.ts new file mode 100644 index 000000000..9244f2a5a --- /dev/null +++ b/test/extension-api-contracts/src/client.ts @@ -0,0 +1,32 @@ +import type { CapturedRequest } from "./mock-publisher-server"; + +export type ExtensionContractStatus = "success" | "not_found" | "conflict" | "error"; + +export interface ExtensionContractResult { + status: ExtensionContractStatus; + result: T; + capturedRequests: CapturedRequest[]; +} + +export interface ExtensionContractClient { + // Configurations + getConfigurations(dir: string): Promise; + getConfiguration(name: string, dir: string): Promise; + createOrUpdateConfiguration(name: string, body: unknown, dir: string): Promise; + deleteConfiguration(name: string, dir: string): Promise; + + // Credentials + listCredentials(): Promise; + createCredential(body: unknown): Promise; + getCredential(guid: string): Promise; + deleteCredential(guid: string): Promise; + resetCredentials(): Promise; + testCredentials(url: string, insecure: boolean, apiKey?: string): Promise; + + // Deployments + getDeployments(dir: string): Promise; + getDeployment(id: string, dir: string): Promise; + createDeployment(dir: string, account?: string, config?: string, saveName?: string): Promise; + deleteDeployment(saveName: string, dir: string): Promise; + patchDeployment(name: string, dir: string, data: { configName?: string; guid?: string }): Promise; +} diff --git a/test/extension-api-contracts/src/clients/axios-extension-client.ts b/test/extension-api-contracts/src/clients/axios-extension-client.ts new file mode 100644 index 000000000..f445792a4 --- /dev/null +++ b/test/extension-api-contracts/src/clients/axios-extension-client.ts @@ -0,0 +1,293 @@ +import axios, { AxiosError, type AxiosInstance } from "axios"; +import type { + ExtensionContractClient, + ExtensionContractResult, + ExtensionContractStatus, +} from "../client"; +import type { CapturedRequest } from "../mock-publisher-server"; + +function mapStatus(httpStatus: number): ExtensionContractStatus { + switch (httpStatus) { + case 200: + case 201: + case 204: + return "success"; + case 404: + return "not_found"; + case 409: + return "conflict"; + default: + return "error"; + } +} + +export class AxiosExtensionClient implements ExtensionContractClient { + private client: AxiosInstance; + private mockUrl: string; + + constructor(mockUrl: string) { + this.mockUrl = mockUrl; + this.client = axios.create({ + baseURL: mockUrl, + // Match extension behavior: don't throw on non-2xx + validateStatus: () => true, + }); + } + + private async getCapturedRequests( + pathFilter?: string, + ): Promise { + const res = await fetch(`${this.mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; + } + + private async wrapCall( + fn: () => Promise<{ status: number; data: T }>, + pathFilter?: string, + ): Promise> { + try { + const response = await fn(); + const captured = pathFilter + ? await this.getCapturedRequests(pathFilter) + : []; + return { + status: mapStatus(response.status), + result: response.data, + capturedRequests: captured, + }; + } catch (err) { + if (err instanceof AxiosError && err.response) { + const captured = pathFilter + ? await this.getCapturedRequests(pathFilter) + : []; + return { + status: mapStatus(err.response.status), + result: err.response.data as T, + capturedRequests: captured, + }; + } + throw err; + } + } + + // --- Configurations --- + + // Mirrors: Configurations.getAll(dir) → GET /configurations?dir=... + // Note: The real extension uses `/configurations` (leading slash), which with + // Axios resolves relative to the origin, bypassing any path in baseURL. + async getConfigurations(dir: string): Promise { + return this.wrapCall( + () => + this.client.get("/configurations", { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.get(configName, dir) → GET /configurations/:name?dir=... + async getConfiguration( + name: string, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.get(`/configurations/${encodedName}`, { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.createOrUpdate(configName, cfg, dir) → PUT configurations/:name?dir=... + // Note: The real extension uses `configurations/` (no leading slash) for this method. + async createOrUpdateConfiguration( + name: string, + body: unknown, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.put(`configurations/${encodedName}`, body, { + params: { dir }, + }), + "/configurations", + ); + } + + // Mirrors: Configurations.delete(configName, dir) → DELETE configurations/:name?dir=... + // Note: The real extension uses `configurations/` (no leading slash) for this method. + async deleteConfiguration( + name: string, + dir: string, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.delete(`configurations/${encodedName}`, { + params: { dir }, + }), + "/configurations", + ); + } + + // --- Credentials --- + + // Mirrors: Credentials.list() → GET credentials + // Note: The real extension uses `credentials` (no leading slash). + async listCredentials(): Promise { + return this.wrapCall( + () => this.client.get("credentials"), + "/credentials", + ); + } + + // Mirrors: Credentials.connectCreate(data, serverType) → POST credentials + // Note: The real extension uses `credentials` (no leading slash). + async createCredential(body: unknown): Promise { + return this.wrapCall( + () => this.client.post("credentials", body), + "/credentials", + ); + } + + // Mirrors: Credentials.get(guid) → GET credentials/:guid + async getCredential(guid: string): Promise { + return this.wrapCall( + () => this.client.get(`credentials/${guid}`), + "/credentials", + ); + } + + // Mirrors: Credentials.delete(guid) → DELETE credentials/:guid + async deleteCredential(guid: string): Promise { + return this.wrapCall( + () => this.client.delete(`credentials/${guid}`), + "/credentials", + ); + } + + // Mirrors: Credentials.reset() → DELETE credentials + async resetCredentials(): Promise { + return this.wrapCall( + () => this.client.delete("credentials"), + "/credentials", + ); + } + + // Mirrors: Credentials.test(url, insecure, apiKey?) → POST test-credentials + // Note: The real extension uses `test-credentials` (no leading slash). + async testCredentials( + url: string, + insecure: boolean, + apiKey?: string, + ): Promise { + return this.wrapCall( + () => + this.client.post("test-credentials", { + url, + apiKey, + insecure, + }), + "/test-credentials", + ); + } + + // --- Deployments --- + + // Mirrors: ContentRecords.getAll(dir) → GET /deployments?dir=... + // Note: The real extension uses `/deployments` (leading slash). + async getDeployments(dir: string): Promise { + return this.wrapCall( + () => + this.client.get("/deployments", { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.get(id, dir) → GET deployments/:id?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async getDeployment( + id: string, + dir: string, + ): Promise { + const encodedId = encodeURIComponent(id); + return this.wrapCall( + () => + this.client.get(`deployments/${encodedId}`, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.createNew(dir, account?, config?, saveName?) → POST /deployments?dir=... + // Note: The real extension uses `/deployments` (leading slash). + async createDeployment( + dir: string, + account?: string, + config?: string, + saveName?: string, + ): Promise { + const data = { + account, + config, + saveName, + }; + return this.wrapCall( + () => + this.client.post("/deployments", data, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.delete(saveName, dir) → DELETE deployments/:name?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async deleteDeployment( + saveName: string, + dir: string, + ): Promise { + const encodedSaveName = encodeURIComponent(saveName); + return this.wrapCall( + () => + this.client.delete(`deployments/${encodedSaveName}`, { + params: { dir }, + }), + "/deployments", + ); + } + + // Mirrors: ContentRecords.patch(name, dir, data) → PATCH deployments/:name?dir=... + // Note: The real extension uses `deployments/` (no leading slash). + async patchDeployment( + name: string, + dir: string, + data: { configName?: string; guid?: string }, + ): Promise { + const encodedName = encodeURIComponent(name); + return this.wrapCall( + () => + this.client.patch( + `deployments/${encodedName}`, + { + configurationName: data.configName, + id: data.guid, + }, + { + params: { dir }, + }, + ), + "/deployments", + ); + } +} diff --git a/test/extension-api-contracts/src/clients/fetch-reference-client.ts b/test/extension-api-contracts/src/clients/fetch-reference-client.ts new file mode 100644 index 000000000..cf089be2a --- /dev/null +++ b/test/extension-api-contracts/src/clients/fetch-reference-client.ts @@ -0,0 +1,74 @@ +import type { ExtensionContractClient, ExtensionContractResult } from "../client"; + +/** + * Stub client for a future fetch-based implementation (e.g., Positron). + * Each method will call the Publisher API using native fetch. + * For now, all methods throw "Not implemented yet". + */ +export class FetchReferenceClient implements ExtensionContractClient { + // Configurations + + async getConfigurations(_dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async getConfiguration(_name: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async createOrUpdateConfiguration(_name: string, _body: unknown, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteConfiguration(_name: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + // Credentials + + async listCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + async createCredential(_body: unknown): Promise { + throw new Error("Not implemented yet"); + } + + async getCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteCredential(_guid: string): Promise { + throw new Error("Not implemented yet"); + } + + async resetCredentials(): Promise { + throw new Error("Not implemented yet"); + } + + async testCredentials(_url: string, _insecure: boolean, _apiKey?: string): Promise { + throw new Error("Not implemented yet"); + } + + // Deployments + + async getDeployments(_dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async getDeployment(_id: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async createDeployment(_dir: string, _account?: string, _config?: string, _saveName?: string): Promise { + throw new Error("Not implemented yet"); + } + + async deleteDeployment(_saveName: string, _dir: string): Promise { + throw new Error("Not implemented yet"); + } + + async patchDeployment(_name: string, _dir: string, _data: { configName?: string; guid?: string }): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap new file mode 100644 index 000000000..bf1db119c --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/configurations.test.ts.snap @@ -0,0 +1,92 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Configurations > createOrUpdateConfiguration (PUT /configurations/:name) > snapshot > matches expected response shape 1`] = ` +{ + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "new-config", + "configurationPath": "/workspace/.posit/publish/new-config.toml", + "configurationRelPath": ".posit/publish/new-config.toml", + "projectDir": "/workspace", +} +`; + +exports[`Configurations > getConfiguration (GET /configurations/:name) > snapshot > matches expected response shape 1`] = ` +{ + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", +} +`; + +exports[`Configurations > getConfigurations (GET /configurations) > snapshot > matches expected response shape 1`] = ` +[ + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "app.py", + "files": [ + "app.py", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "python-fastapi", + }, + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + }, + { + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "entrypoint": "notebook.ipynb", + "files": [ + "notebook.ipynb", + "requirements.txt", + ], + "python": { + "packageFile": "requirements.txt", + "packageManager": "pip", + "version": "3.11.3", + }, + "type": "jupyter-notebook", + }, + "configurationName": "my-notebook", + "configurationPath": "/workspace/.posit/publish/my-notebook.toml", + "configurationRelPath": ".posit/publish/my-notebook.toml", + "projectDir": "/workspace", + }, +] +`; diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap new file mode 100644 index 000000000..ad2a86928 --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/credentials.test.ts.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Credentials > createCredential (POST /credentials) > snapshot > matches expected response shape 1`] = ` +{ + "apiKey": "new-api-key", + "guid": "new-cred-guid-999", + "name": "new-server", + "serverType": "connect", + "url": "https://new-server.example.com", +} +`; + +exports[`Credentials > getCredential (GET /credentials/:guid) > snapshot > matches expected response shape 1`] = ` +{ + "apiKey": "test-api-key-12345", + "guid": "abc-123-def-456", + "name": "my-connect-server", + "serverType": "connect", + "url": "https://connect.example.com", +} +`; + +exports[`Credentials > listCredentials (GET /credentials) > snapshot > matches expected response shape 1`] = ` +[ + { + "apiKey": "test-api-key-12345", + "guid": "abc-123-def-456", + "name": "my-connect-server", + "serverType": "connect", + "url": "https://connect.example.com", + }, + { + "apiKey": "staging-api-key", + "guid": "ghi-789-jkl-012", + "name": "staging-server", + "serverType": "connect", + "url": "https://staging.example.com", + }, +] +`; + +exports[`Credentials > resetCredentials (DELETE /credentials) > snapshot > matches expected response shape 1`] = ` +{ + "backupFile": "/tmp/credentials-backup-20240101.json", +} +`; + +exports[`Credentials > testCredentials (POST /test-credentials) > snapshot > matches expected response shape 1`] = ` +{ + "error": null, + "serverType": "connect", + "url": "https://connect.example.com", + "user": { + "email": "bob@example.com", + "first_name": "Bob", + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "last_name": "Bobberson", + "username": "bob", + }, +} +`; diff --git a/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap b/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap new file mode 100644 index 000000000..174a9c56a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/__snapshots__/deployments.test.ts.snap @@ -0,0 +1,87 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Deployments > createDeployment (POST /deployments) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-15T10:00:00Z", + "dashboardUrl": "", + "deployedAt": "", + "deploymentName": "new-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/new-deployment.toml", + "directUrl": "", + "id": "", + "logsUrl": "", + "projectDir": "/workspace", + "saveName": "new-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "new", +} +`; + +exports[`Deployments > getDeployment (GET /deployments/:id) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "bundle-456", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", +} +`; + +exports[`Deployments > getDeployments (GET /deployments) > snapshot > matches expected response shape 1`] = ` +[ + { + "bundleId": "bundle-456", + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", + }, +] +`; + +exports[`Deployments > patchDeployment (PATCH /deployments/:name) > snapshot > matches expected response shape 1`] = ` +{ + "bundleId": "bundle-456", + "configurationName": "updated-config", + "configurationPath": "/workspace/.posit/publish/updated-config.toml", + "createdAt": "2024-01-01T00:00:00Z", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "deployedAt": "2024-01-02T12:00:00Z", + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "directUrl": "https://connect.example.com/content/content-id-123/", + "id": "content-id-123", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "projectDir": "/workspace", + "saveName": "my-deployment", + "serverType": "connect", + "serverUrl": "https://connect.example.com", + "state": "deployed", +} +`; diff --git a/test/extension-api-contracts/src/endpoints/configurations.test.ts b/test/extension-api-contracts/src/endpoints/configurations.test.ts new file mode 100644 index 000000000..b6df2c92a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/configurations.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Configurations", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("getConfigurations (GET /configurations)", () => { + describe("request correctness", () => { + it("sends GET to /configurations", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/configurations"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("accepts content-type application/json", async () => { + const client = getClient(); + await client.getConfigurations("/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].headers["accept"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns array of configurations", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.result).toBeInstanceOf(Array); + + const configs = result.result as any[]; + expect(configs.length).toBeGreaterThan(0); + }); + + it("parses configuration fields", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + + const configs = result.result as any[]; + const first = configs[0]; + expect(first.configurationName).toBe("my-app"); + expect(first.configuration).toBeDefined(); + expect(first.configuration.type).toBe("python-fastapi"); + expect(first.configuration.entrypoint).toBe("app.py"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getConfigurations("/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getConfiguration (GET /configurations/:name)", () => { + describe("request correctness", () => { + it("sends GET to /configurations/:name", async () => { + const client = getClient(); + await client.getConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/configurations/my-app"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("encodes special characters in name", async () => { + const client = getClient(); + await client.getConfiguration("my app/config", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].path).toBe("/configurations/my%20app%2Fconfig"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns a single configuration object", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + + const config = result.result as any; + expect(config.configurationName).toBe("my-app"); + expect(config.configuration).toBeDefined(); + expect(config.configuration.type).toBe("python-fastapi"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getConfiguration("my-app", "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createOrUpdateConfiguration (PUT /configurations/:name)", () => { + const newConfig = { + type: "python-fastapi", + entrypoint: "app.py", + files: ["app.py", "requirements.txt"], + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + }, + }; + + describe("request correctness", () => { + it("sends PUT to /configurations/:name", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("PUT"); + expect(requests[0].path).toBe("/configurations/new-config"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends configuration as JSON body", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].body).not.toBeNull(); + const body = JSON.parse(requests[0].body!); + expect(body.type).toBe("python-fastapi"); + expect(body.entrypoint).toBe("app.py"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns the created configuration", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + + const config = result.result as any; + expect(config.configurationName).toBe("new-config"); + expect(config.configuration).toBeDefined(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createOrUpdateConfiguration("new-config", newConfig, "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteConfiguration (DELETE /configurations/:name)", () => { + describe("request correctness", () => { + it("sends DELETE to /configurations/:name", async () => { + const client = getClient(); + await client.deleteConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/configurations/my-app"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.deleteConfiguration("my-app", "/workspace"); + + const requests = await getMockRequests("/configurations"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteConfiguration("my-app", "/workspace"); + expect(result.status).toBe("success"); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/endpoints/credentials.test.ts b/test/extension-api-contracts/src/endpoints/credentials.test.ts new file mode 100644 index 000000000..4ad7d1681 --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/credentials.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Credentials", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("listCredentials (GET /credentials)", () => { + describe("request correctness", () => { + it("sends GET to /credentials", async () => { + const client = getClient(); + await client.listCredentials(); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/credentials"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.status).toBe("success"); + }); + + it("returns array of credentials", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.result).toBeInstanceOf(Array); + + const creds = result.result as any[]; + expect(creds.length).toBeGreaterThan(0); + }); + + it("parses credential fields", async () => { + const client = getClient(); + const result = await client.listCredentials(); + + const creds = result.result as any[]; + const first = creds[0]; + expect(first.guid).toBe("abc-123-def-456"); + expect(first.name).toBe("my-connect-server"); + expect(first.url).toBe("https://connect.example.com"); + expect(first.serverType).toBe("connect"); + expect(first.apiKey).toBe("test-api-key-12345"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.listCredentials(); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createCredential (POST /credentials)", () => { + const newCred = { + name: "new-server", + url: "https://new-server.example.com", + apiKey: "new-api-key", + serverType: "connect", + }; + + describe("request correctness", () => { + it("sends POST to /credentials", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/credentials"); + }); + + it("sends credential data as JSON body", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests[0].body).not.toBeNull(); + const body = JSON.parse(requests[0].body!); + expect(body.name).toBe("new-server"); + expect(body.url).toBe("https://new-server.example.com"); + expect(body.apiKey).toBe("new-api-key"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createCredential(newCred); + + const requests = await getMockRequests("/credentials"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 201 response", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + expect(result.status).toBe("success"); + }); + + it("returns the created credential", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + + const cred = result.result as any; + expect(cred.guid).toBeDefined(); + expect(cred.name).toBe("new-server"); + expect(cred.url).toBe("https://new-server.example.com"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createCredential(newCred); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getCredential (GET /credentials/:guid)", () => { + describe("request correctness", () => { + it("sends GET to /credentials/:guid", async () => { + const client = getClient(); + await client.getCredential("abc-123-def-456"); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/credentials/abc-123-def-456"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + expect(result.status).toBe("success"); + }); + + it("returns a single credential", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + + const cred = result.result as any; + expect(cred.guid).toBe("abc-123-def-456"); + expect(cred.name).toBe("my-connect-server"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getCredential("abc-123-def-456"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteCredential (DELETE /credentials/:guid)", () => { + describe("request correctness", () => { + it("sends DELETE to /credentials/:guid", async () => { + const client = getClient(); + await client.deleteCredential("abc-123-def-456"); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/credentials/abc-123-def-456"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteCredential("abc-123-def-456"); + expect(result.status).toBe("success"); + }); + }); + }); + + describe("resetCredentials (DELETE /credentials)", () => { + describe("request correctness", () => { + it("sends DELETE to /credentials (no guid)", async () => { + const client = getClient(); + await client.resetCredentials(); + + const requests = await getMockRequests("/credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/credentials"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + expect(result.status).toBe("success"); + }); + + it("returns backup file path", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + + const body = result.result as any; + expect(body.backupFile).toBeDefined(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.resetCredentials(); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("testCredentials (POST /test-credentials)", () => { + describe("request correctness", () => { + it("sends POST to /test-credentials", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", false, "test-key"); + + const requests = await getMockRequests("/test-credentials"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/test-credentials"); + }); + + it("sends url, insecure, and apiKey in body", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", false, "test-key"); + + const requests = await getMockRequests("/test-credentials"); + const body = JSON.parse(requests[0].body!); + expect(body.url).toBe("https://connect.example.com"); + expect(body.insecure).toBe(false); + expect(body.apiKey).toBe("test-key"); + }); + + it("sends url and insecure without apiKey when not provided", async () => { + const client = getClient(); + await client.testCredentials("https://connect.example.com", true); + + const requests = await getMockRequests("/test-credentials"); + const body = JSON.parse(requests[0].body!); + expect(body.url).toBe("https://connect.example.com"); + expect(body.insecure).toBe(true); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + expect(result.status).toBe("success"); + }); + + it("parses user fields from test result", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + + const body = result.result as any; + expect(body.user).toBeDefined(); + expect(body.user.username).toBe("bob"); + expect(body.error).toBeNull(); + expect(body.serverType).toBe("connect"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.testCredentials("https://connect.example.com", false, "test-key"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/endpoints/deployments.test.ts b/test/extension-api-contracts/src/endpoints/deployments.test.ts new file mode 100644 index 000000000..d0738d49a --- /dev/null +++ b/test/extension-api-contracts/src/endpoints/deployments.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, clearMockRequests, getMockRequests } from "../helpers"; + +describe("Deployments", () => { + beforeEach(async () => { + await clearMockRequests(); + }); + + describe("getDeployments (GET /deployments)", () => { + describe("request correctness", () => { + it("sends GET to /deployments", async () => { + const client = getClient(); + await client.getDeployments("/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/deployments"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getDeployments("/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns array of deployments", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.result).toBeInstanceOf(Array); + + const deployments = result.result as any[]; + expect(deployments.length).toBeGreaterThan(0); + }); + + it("parses deployment fields", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + + const deployments = result.result as any[]; + const first = deployments[0]; + expect(first.deploymentName).toBe("my-deployment"); + expect(first.state).toBe("deployed"); + expect(first.serverType).toBe("connect"); + expect(first.serverUrl).toBe("https://connect.example.com"); + expect(first.configurationName).toBe("my-app"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getDeployments("/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("getDeployment (GET /deployments/:id)", () => { + describe("request correctness", () => { + it("sends GET to /deployments/:id", async () => { + const client = getClient(); + await client.getDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.getDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("encodes special characters in id", async () => { + const client = getClient(); + await client.getDeployment("my deploy/name", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].path).toBe("/deployments/my%20deploy%2Fname"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + expect(result.status).toBe("success"); + }); + + it("returns a single deployment object", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + + const deployment = result.result as any; + expect(deployment.deploymentName).toBe("my-deployment"); + expect(deployment.state).toBe("deployed"); + expect(deployment.serverType).toBe("connect"); + expect(deployment.id).toBe("content-id-123"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.getDeployment("my-deployment", "/workspace"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("createDeployment (POST /deployments)", () => { + describe("request correctness", () => { + it("sends POST to /deployments", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].path).toBe("/deployments"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends account, config, and saveName in body", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + const body = JSON.parse(requests[0].body!); + expect(body.account).toBe("my-account"); + expect(body.config).toBe("my-config"); + expect(body.saveName).toBe("new-deployment"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + expect(result.status).toBe("success"); + }); + + it("returns the created deployment", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + + const deployment = result.result as any; + expect(deployment.deploymentName).toBe("new-deployment"); + expect(deployment.state).toBe("new"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.createDeployment("/workspace", "my-account", "my-config", "new-deployment"); + expect(result.result).toMatchSnapshot(); + }); + }); + }); + + describe("deleteDeployment (DELETE /deployments/:name)", () => { + describe("request correctness", () => { + it("sends DELETE to /deployments/:name", async () => { + const client = getClient(); + await client.deleteDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.deleteDeployment("my-deployment", "/workspace"); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 response", async () => { + const client = getClient(); + const result = await client.deleteDeployment("my-deployment", "/workspace"); + expect(result.status).toBe("success"); + }); + }); + }); + + describe("patchDeployment (PATCH /deployments/:name)", () => { + describe("request correctness", () => { + it("sends PATCH to /deployments/:name", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("PATCH"); + expect(requests[0].path).toBe("/deployments/my-deployment"); + }); + + it("sends dir as query parameter", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].query.dir).toBe("/workspace"); + }); + + it("sends configurationName and id in body (matching extension mapping)", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + guid: "new-guid-456", + }); + + const requests = await getMockRequests("/deployments"); + const body = JSON.parse(requests[0].body!); + expect(body.configurationName).toBe("updated-config"); + expect(body.id).toBe("new-guid-456"); + }); + + it("sends Content-Type application/json header", async () => { + const client = getClient(); + await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const requests = await getMockRequests("/deployments"); + expect(requests[0].headers["content-type"]).toContain("application/json"); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + expect(result.status).toBe("success"); + }); + + it("returns the patched deployment", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + + const deployment = result.result as any; + expect(deployment.configurationName).toBe("updated-config"); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const result = await client.patchDeployment("my-deployment", "/workspace", { + configName: "updated-config", + }); + expect(result.result).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json new file mode 100644 index 000000000..899e01602 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-created.json @@ -0,0 +1,17 @@ +{ + "configurationName": "new-config", + "configurationPath": "/workspace/.posit/publish/new-config.toml", + "configurationRelPath": ".posit/publish/new-config.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json new file mode 100644 index 000000000..c2cd808fc --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configuration-single.json @@ -0,0 +1,17 @@ +{ + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json new file mode 100644 index 000000000..af9616187 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/configurations-list.json @@ -0,0 +1,36 @@ +[ + { + "configurationName": "my-app", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "configurationRelPath": ".posit/publish/my-app.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "python-fastapi", + "entrypoint": "app.py", + "files": ["app.py", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } + }, + { + "configurationName": "my-notebook", + "configurationPath": "/workspace/.posit/publish/my-notebook.toml", + "configurationRelPath": ".posit/publish/my-notebook.toml", + "projectDir": "/workspace", + "configuration": { + "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "jupyter-notebook", + "entrypoint": "notebook.ipynb", + "files": ["notebook.ipynb", "requirements.txt"], + "python": { + "version": "3.11.3", + "packageFile": "requirements.txt", + "packageManager": "pip" + } + } + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json new file mode 100644 index 000000000..2e8d38a26 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-created.json @@ -0,0 +1,7 @@ +{ + "guid": "new-cred-guid-999", + "name": "new-server", + "url": "https://new-server.example.com", + "serverType": "connect", + "apiKey": "new-api-key" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json new file mode 100644 index 000000000..941d3cc34 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credential-single.json @@ -0,0 +1,7 @@ +{ + "guid": "abc-123-def-456", + "name": "my-connect-server", + "url": "https://connect.example.com", + "serverType": "connect", + "apiKey": "test-api-key-12345" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json new file mode 100644 index 000000000..6f18a68c0 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-list.json @@ -0,0 +1,16 @@ +[ + { + "guid": "abc-123-def-456", + "name": "my-connect-server", + "url": "https://connect.example.com", + "serverType": "connect", + "apiKey": "test-api-key-12345" + }, + { + "guid": "ghi-789-jkl-012", + "name": "staging-server", + "url": "https://staging.example.com", + "serverType": "connect", + "apiKey": "staging-api-key" + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json new file mode 100644 index 000000000..c27a07432 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/credentials-reset.json @@ -0,0 +1,3 @@ +{ + "backupFile": "/tmp/credentials-backup-20240101.json" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json new file mode 100644 index 000000000..7f650af20 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-created.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "new-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/new-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "new-deployment", + "state": "new", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "", + "dashboardUrl": "", + "directUrl": "", + "logsUrl": "", + "createdAt": "2024-01-15T10:00:00Z", + "deployedAt": "", + "bundleId": "", + "configurationName": "my-app" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json new file mode 100644 index 000000000..d6c3b20fa --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-patched.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/updated-config.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "updated-config" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json new file mode 100644 index 000000000..bed520589 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployment-single.json @@ -0,0 +1,18 @@ +{ + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "my-app" +} diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json b/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json new file mode 100644 index 000000000..b3bdbd738 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/deployments-list.json @@ -0,0 +1,20 @@ +[ + { + "deploymentName": "my-deployment", + "deploymentPath": "/workspace/.posit/publish/deployments/my-deployment.toml", + "projectDir": "/workspace", + "configurationPath": "/workspace/.posit/publish/my-app.toml", + "saveName": "my-deployment", + "state": "deployed", + "serverUrl": "https://connect.example.com", + "serverType": "connect", + "id": "content-id-123", + "dashboardUrl": "https://connect.example.com/connect/#/apps/content-id-123", + "directUrl": "https://connect.example.com/content/content-id-123/", + "logsUrl": "https://connect.example.com/connect/#/apps/content-id-123/logs", + "createdAt": "2024-01-01T00:00:00Z", + "deployedAt": "2024-01-02T12:00:00Z", + "bundleId": "bundle-456", + "configurationName": "my-app" + } +] diff --git a/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json b/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json new file mode 100644 index 000000000..5c48d2452 --- /dev/null +++ b/test/extension-api-contracts/src/fixtures/publisher-responses/test-credentials.json @@ -0,0 +1,12 @@ +{ + "error": null, + "serverType": "connect", + "url": "https://connect.example.com", + "user": { + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "email": "bob@example.com" + } +} diff --git a/test/extension-api-contracts/src/helpers.ts b/test/extension-api-contracts/src/helpers.ts new file mode 100644 index 000000000..26d3e97c6 --- /dev/null +++ b/test/extension-api-contracts/src/helpers.ts @@ -0,0 +1,46 @@ +import type { ExtensionContractClient } from "./client"; +import type { CapturedRequest } from "./mock-publisher-server"; +import { AxiosExtensionClient } from "./clients/axios-extension-client"; +import { FetchReferenceClient } from "./clients/fetch-reference-client"; + +let _client: ExtensionContractClient | null = null; + +export function getClient(): ExtensionContractClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "axios"; + if (clientType === "axios") { + const mockUrl = getMockPublisherUrl(); + _client = new AxiosExtensionClient(mockUrl); + } else { + _client = new FetchReferenceClient(); + } + return _client; +} + +export function getMockPublisherUrl(): string { + const url = process.env.MOCK_PUBLISHER_URL; + if (!url) { + throw new Error( + "MOCK_PUBLISHER_URL not set. Is the global setup running correctly?", + ); + } + return url; +} + +export async function clearMockRequests(): Promise { + const mockUrl = getMockPublisherUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); +} + +export async function getMockRequests( + pathFilter?: string, +): Promise { + const mockUrl = getMockPublisherUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; +} diff --git a/test/extension-api-contracts/src/mock-publisher-server.ts b/test/extension-api-contracts/src/mock-publisher-server.ts new file mode 100644 index 000000000..949c5ab6f --- /dev/null +++ b/test/extension-api-contracts/src/mock-publisher-server.ts @@ -0,0 +1,282 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { URL } from "node:url"; + +export interface CapturedRequest { + method: string; + path: string; + query: Record; + headers: Record; + body: string | null; +} + +interface RouteHandler { + method: string; + pattern: RegExp; + status: number; + response: unknown; + contentType?: string; +} + +const FIXTURES_DIR = resolve(__dirname, "fixtures", "publisher-responses"); + +function loadFixture(name: string): unknown { + const content = readFileSync(resolve(FIXTURES_DIR, name), "utf-8"); + return JSON.parse(content); +} + +export class MockPublisherServer { + private server: ReturnType | null = null; + private captured: CapturedRequest[] = []; + private routes: RouteHandler[] = []; + private _port = 0; + + constructor() { + this.registerDefaultRoutes(); + } + + get port(): number { + return this._port; + } + + get url(): string { + return `http://localhost:${this._port}`; + } + + private registerDefaultRoutes(): void { + // Routes are matched by first match, so more specific patterns must come first. + + // --- Configurations --- + + // GET /configurations — getAll + this.routes.push({ + method: "GET", + pattern: /^\/configurations$/, + status: 200, + response: loadFixture("configurations-list.json"), + }); + + // GET /configurations/:name — get single + this.routes.push({ + method: "GET", + pattern: /^\/configurations\/[^/]+$/, + status: 200, + response: loadFixture("configuration-single.json"), + }); + + // PUT /configurations/:name — createOrUpdate + this.routes.push({ + method: "PUT", + pattern: /^\/configurations\/[^/]+$/, + status: 200, + response: loadFixture("configuration-created.json"), + }); + + // DELETE /configurations/:name — delete + this.routes.push({ + method: "DELETE", + pattern: /^\/configurations\/[^/]+$/, + status: 204, + response: null, + }); + + // --- Credentials --- + + // POST /test-credentials — test + this.routes.push({ + method: "POST", + pattern: /^\/test-credentials$/, + status: 200, + response: loadFixture("test-credentials.json"), + }); + + // GET /credentials — list + this.routes.push({ + method: "GET", + pattern: /^\/credentials$/, + status: 200, + response: loadFixture("credentials-list.json"), + }); + + // POST /credentials — create + this.routes.push({ + method: "POST", + pattern: /^\/credentials$/, + status: 201, + response: loadFixture("credential-created.json"), + }); + + // DELETE /credentials — reset (must come before single-credential DELETE) + // We distinguish by checking if there's a path segment after /credentials + // The pattern for reset is exactly /credentials with no trailing segment + // But we need the single DELETE to match /credentials/:guid + // Since both are DELETE, we handle this in the route matching by ordering: + // specific (with guid) first, then general (reset) + + // DELETE /credentials/:guid — delete single + this.routes.push({ + method: "DELETE", + pattern: /^\/credentials\/[^/]+$/, + status: 204, + response: null, + }); + + // DELETE /credentials — reset all + this.routes.push({ + method: "DELETE", + pattern: /^\/credentials$/, + status: 200, + response: loadFixture("credentials-reset.json"), + }); + + // GET /credentials/:guid — get single + this.routes.push({ + method: "GET", + pattern: /^\/credentials\/[^/]+$/, + status: 200, + response: loadFixture("credential-single.json"), + }); + + // --- Deployments --- + + // GET /deployments — getAll + this.routes.push({ + method: "GET", + pattern: /^\/deployments$/, + status: 200, + response: loadFixture("deployments-list.json"), + }); + + // POST /deployments — createNew + this.routes.push({ + method: "POST", + pattern: /^\/deployments$/, + status: 200, + response: loadFixture("deployment-created.json"), + }); + + // GET /deployments/:id — get single + this.routes.push({ + method: "GET", + pattern: /^\/deployments\/[^/]+$/, + status: 200, + response: loadFixture("deployment-single.json"), + }); + + // PATCH /deployments/:name — patch + this.routes.push({ + method: "PATCH", + pattern: /^\/deployments\/[^/]+$/, + status: 200, + response: loadFixture("deployment-patched.json"), + }); + + // DELETE /deployments/:name — delete + this.routes.push({ + method: "DELETE", + pattern: /^\/deployments\/[^/]+$/, + status: 204, + response: null, + }); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.listen(0, "localhost", () => { + const addr = this.server!.address(); + if (addr && typeof addr === "object") { + this._port = addr.port; + } + resolve(); + }); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const method = req.method ?? "GET"; + const rawUrl = req.url ?? "/"; + + // Parse URL to separate path from query string + const parsed = new URL(rawUrl, `http://localhost:${this._port}`); + const path = parsed.pathname; + const query: Record = {}; + for (const [key, value] of parsed.searchParams.entries()) { + query[key] = value; + } + + // Control endpoint: GET captured requests + if (method === "GET" && path === "/__test__/requests") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(this.captured)); + return; + } + + // Control endpoint: clear captured requests + if (method === "DELETE" && path === "/__test__/requests") { + this.captured = []; + res.writeHead(204); + res.end(); + return; + } + + // Collect request body, then capture and route + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const bodyStr = chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + + // Flatten headers to Record + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(", "); + } + } + + // Capture the request + this.captured.push({ method, path, query, headers, body: bodyStr }); + + // Find matching route + const route = this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); + + if (route) { + const contentType = route.contentType ?? "application/json"; + + if (route.response === null || route.response === undefined) { + // No-body response (e.g. 204) + res.writeHead(route.status); + res.end(); + } else if (Buffer.isBuffer(route.response)) { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else if (typeof route.response === "string") { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(JSON.stringify(route.response)); + } + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + } +} diff --git a/test/extension-api-contracts/src/setup.ts b/test/extension-api-contracts/src/setup.ts new file mode 100644 index 000000000..19e7e8317 --- /dev/null +++ b/test/extension-api-contracts/src/setup.ts @@ -0,0 +1,25 @@ +import { MockPublisherServer } from "./mock-publisher-server"; + +let mockServer: MockPublisherServer | null = null; + +export async function setup() { + mockServer = new MockPublisherServer(); + await mockServer.start(); + + process.env.MOCK_PUBLISHER_URL = mockServer.url; + + const backend = process.env.API_BACKEND ?? "axios"; + process.env.__CLIENT_TYPE = backend; + + console.log(`[setup] Mock Publisher server running at ${mockServer.url}`); + console.log(`[setup] Client type: ${backend}`); +} + +export async function teardown() { + if (mockServer) { + await mockServer.stop(); + mockServer = null; + } + + console.log("[teardown] Mock Publisher server stopped"); +} diff --git a/test/extension-api-contracts/tsconfig.json b/test/extension-api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/extension-api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/extension-api-contracts/vitest.config.ts b/test/extension-api-contracts/vitest.config.ts new file mode 100644 index 000000000..55112af49 --- /dev/null +++ b/test/extension-api-contracts/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + fileParallelism: false, + }, +}); From d29fe8dae53903db27458940e2ac8721b68c9f56 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:29:32 -0500 Subject: [PATCH 8/8] Add entrypoints and inspect contract tests with fixture workspaces Extends the api-contracts suite with tests for GET /api/entrypoints and POST /api/inspect endpoints. Adds fixture workspaces for jupyter-nb, quarto-doc, rmd-static, and shiny-python content types to enable inspection testing across multiple project types. Co-Authored-By: Claude Opus 4.6 --- test/api-contracts/src/client.ts | 10 + .../src/clients/go-http-client.ts | 32 ++ .../src/clients/typescript-direct-client.ts | 18 + .../__snapshots__/entrypoints.test.ts.snap | 18 + .../__snapshots__/inspect.test.ts.snap | 458 ++++++++++++++++++ .../src/endpoints/entrypoints.test.ts | 35 ++ .../src/endpoints/inspect.test.ts | 140 ++++++ .../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 + 14 files changed, 780 insertions(+) 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/entrypoints.test.ts create mode 100644 test/api-contracts/src/endpoints/inspect.test.ts 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 diff --git a/test/api-contracts/src/client.ts b/test/api-contracts/src/client.ts index 0beff30c7..e87dda358 100644 --- a/test/api-contracts/src/client.ts +++ b/test/api-contracts/src/client.ts @@ -29,4 +29,14 @@ export interface BackendClient { 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 index 0020498dd..7eb7155af 100644 --- a/test/api-contracts/src/clients/go-http-client.ts +++ b/test/api-contracts/src/clients/go-http-client.ts @@ -154,4 +154,36 @@ export class GoHttpClient implements BackendClient { ); 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 index bae92f1e7..9f22e1303 100644 --- a/test/api-contracts/src/clients/typescript-direct-client.ts +++ b/test/api-contracts/src/clients/typescript-direct-client.ts @@ -75,4 +75,22 @@ export class TypeScriptDirectClient implements BackendClient { 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__/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/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/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