diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bf3232..9061f62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,12 @@ jobs: - name: Multi-node version test run: cd mnode-test && ./docker-test.sh + + - name: Run live API tests + env: + QAS_TEST_URL: ${{ secrets.QAS_TEST_URL }} + QAS_TEST_TOKEN: ${{ secrets.QAS_TEST_TOKEN }} + QAS_TEST_USERNAME: ${{ secrets.QAS_TEST_USERNAME }} + QAS_TEST_PASSWORD: ${{ secrets.QAS_TEST_PASSWORD }} + QAS_DEV_AUTH: ${{ secrets.QAS_DEV_AUTH }} + run: npm run test:live diff --git a/CLAUDE.md b/CLAUDE.md index a05131b..753388a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Node.js compatibility tests: `cd mnode-test && ./docker-test.sh` (requires Docke ### Entry Point & CLI Framework - `src/bin/qasphere.ts` — Entry point (`#!/usr/bin/env node`). Validates Node version, delegates to `run()`. -- `src/commands/main.ts` — Yargs setup. Registers three commands (`junit-upload`, `playwright-json-upload`, `allure-upload`) as instances of the same `ResultUploadCommandModule` class. +- `src/commands/main.ts` — Yargs setup. Registers three upload commands (`junit-upload`, `playwright-json-upload`, `allure-upload`) as instances of `ResultUploadCommandModule`, plus the `api` command. - `src/commands/resultUpload.ts` — `ResultUploadCommandModule` defines CLI options shared by both commands. Loads env vars, then delegates to `ResultUploadCommandHandler`. ### Core Upload Pipeline (src/utils/result-upload/) @@ -62,24 +62,52 @@ The upload flow has two stages handled by two classes, with a shared `MarkerPars - `allureParser.ts` — Parses Allure JSON results directories (`*-result.json` and `*-container.json` files; XML/images ignored). Supports test case linking via TMS links (`type: "tms"`) or marker in test name, maps Allure statuses to QA Sphere result statuses (`unknown→open`, `broken→blocked`), strips ANSI codes and HTML-escapes messages, and resolves attachments via `attachments[].source`. Uses `formatMarker()` from `MarkerParser`. Extracts run-level failure logs from container files by checking `befores`/`afters` fixtures with `failed`/`broken` status — primarily useful for pytest (allure-junit5 and allure-playwright leave container fixtures empty). - `types.ts` — Shared `TestCaseResult`, `ParseResult`, and `Attachment` interfaces used by both parsers. +### API Command (src/commands/api/) + +The `api` command provides direct programmatic access to the QA Sphere public API: `qasphere api [options]`. Each resource (e.g., `projects`, `runs`, `test-cases`) is a subcommand with its own actions. Some resources have nested subgroups (e.g., `qasphere api runs tcases list`). + +**File structure per resource**: + +``` +src/commands/api// +├── command.ts # Yargs command definitions (list, get, create, etc.) and CLI-specific Zod schemas +└── help.ts # Help text and descriptions +``` + +- `main.ts` — Registers all resource subcommands via `.command()` +- `utils.ts` — Shared helpers: `apiHandler()` wraps handlers with lazy env loading and error handling; `printJson()` outputs formatted JSON; `parseJsonArg()` supports inline JSON or `@filename`; `parseAndValidateJsonArg()` and `validateWithSchema()` validate with Zod and produce detailed error messages; `apiDocsEpilog()` appends API doc links + +Important note: Online documentation is available at https://docs.qasphere.com. Most leaf pages have a markdown version available by appending `.md` in the URL. Use the markdown version before falling back to the original URL if the markdown version returns >= 400 status. + +**Key design patterns**: + +- **Lazy env loading**: `QAS_URL`/`QAS_TOKEN` are loaded only when the API is actually called (via `connectApi()`), so CLI validation errors are reported first +- **JSON argument flexibility**: Complex args accept inline JSON or `@filename` references (e.g., `--query-plans @plans.json`) +- **Validation flow**: API-level Zod schemas (in `src/api/*.ts`) validate request structure and strip unknown fields. All commands catch `RequestValidationError` via `.catch(handleValidationError(buildArgumentMap([...])))` to reformat API error paths into CLI argument names (e.g., `--query-plans: [0].tcaseIds: not allowed for "live" runs`). Complex JSON args (e.g., `--query-plans`, `--body`, `--steps`) are pre-validated with `parseAndValidateJsonArg()` / `parseOptionalJsonField()` for early feedback before the API call + ### API Layer (src/api/) Composable fetch wrappers using higher-order functions: -- `utils.ts` — `withBaseUrl`, `withApiKey`, `withJson` decorators that wrap `fetch` -- `index.ts` — `createApi(baseUrl, apiKey)` assembles the API client from sub-modules -- Sub-modules: `projects.ts`, `run.ts`, `tcases.ts`, `file.ts` +- `utils.ts` — `withBaseUrl`, `withApiKey`, `withJson`, `withDevAuth` decorators that wrap `fetch`; `jsonResponse()` for parsing responses; `appendSearchParams()` for building query strings +- `index.ts` — `createApi(baseUrl, apiKey)` assembles the API client from all sub-modules +- `schemas.ts` — Shared types (`ResourceId`, `ResultStatus`, `PaginatedResponse`, `PaginatedRequest`, `MessageResponse`), `RequestValidationError` class, `validateRequest()` helper, and common Zod field definitions (`sortFieldParam`, `sortOrderParam`, `pageParam`, `limitParam`) +- One sub-module per resource (e.g., `projects.ts`, `runs.ts`, `tcases.ts`, `folders.ts`), each exporting a `createApi(fetcher)` factory function. Each module defines Zod schemas for its request types (PascalCase, e.g., `CreateRunRequestSchema`), derives TypeScript types via `z.infer`, and validates inputs with `validateRequest()` inside API functions + +The main `createApi()` composes the fetch chain: `withDevAuth(withApiKey(withBaseUrl(fetch, baseUrl), apiKey))`. ### Configuration (src/utils/) -- `env.ts` — Loads `QAS_TOKEN` and `QAS_URL` from environment variables, `.env`, or `.qaspherecli` (searched up the directory tree) +- `env.ts` — Loads `QAS_TOKEN` and `QAS_URL` from environment variables, `.env`, or `.qaspherecli` (searched up the directory tree). Optional `QAS_DEV_AUTH` adds a dev cookie via the `withDevAuth` fetch decorator - `config.ts` — Constants (required Node version) - `misc.ts` — URL parsing, template string processing (`{env:VAR}`, date placeholders), error handling utilities. Note: marker-related functions have been moved to `MarkerParser.ts` - `version.ts` — Reads version from `package.json` by traversing parent directories ## Testing -Tests use **Vitest** with **MSW** (Mock Service Worker) for API mocking. Test files are in `src/tests/`: +Tests use **Vitest** with **MSW** (Mock Service Worker) for API mocking. Test files are in `src/tests/`. + +### Upload Command Tests - `result-upload.spec.ts` — Integration tests for the full upload flow (JUnit, Playwright, and Allure), with MSW intercepting all API calls. Includes hyphenless and CamelCase marker tests (JUnit only) - `marker-parser.spec.ts` — Unit tests for `MarkerParser` (detection, extraction, matching across all marker formats and command types) @@ -90,6 +118,50 @@ Tests use **Vitest** with **MSW** (Mock Service Worker) for API mocking. Test fi Test fixtures live in `src/tests/fixtures/` (XML files, JSON files, and mock test case data). +### API Command Tests (src/tests/api/) + +Tests for the `api` command are organized by resource under `src/tests/api/`, with one spec file per action (e.g., `projects/list.spec.ts`, `runs/create.spec.ts`). Tests support both mocked and live modes. + +**Shared infrastructure** (`src/tests/api/test-helper.ts`): + +- `baseURL`, `token` — Configured base URL and token (mocked values or real from env vars) +- `useMockServer(...handlers)` — Sets up MSW server with lifecycle hooks (before/after each test) +- `runCli(...args)` — Invokes the CLI programmatically via `run(args)`, captures and parses JSON output. Useful only if the command prints JSON. +- `test` fixture — Extended Vitest `test` that provides a `project` fixture (mock project in mocked mode, real project with cleanup in live mode) +- Helper functions for live tests: `createFolder()`, `createTCase()`, `createRun()` + +**Global setup** (`src/tests/global-setup.ts`): Authenticates against the live API (if env vars are set) and provides a session token for test project cleanup. + +**Test pattern**: Each spec file typically contains: + +1. A `describe('mocked', ...)` block with MSW handlers and assertions on request headers/params +2. Validation error tests checking CLI argument validation +3. Live tests tagged with `{ tags: ['live'] }` that run against a real QA Sphere instance + +**Other tests**: + +- `missing-subcommand-help.spec.ts` — Verifies incomplete commands (e.g., `api` alone, `api projects` alone) show help text +- `api/utils.spec.ts` — Unit tests for API command utility functions + +### Running Tests + +```bash +npm run test # Run all tests (mocked only by default) +npm run test:live # Run live tests only (requires env vars) +``` + +### Environment Variables for Live API Tests + +Live tests require all four variables to be set; otherwise tests run in mocked mode only: + +| Variable | Purpose | +| ------------------- | ----------------------------------------------------- | +| `QAS_TEST_URL` | Base URL of the QA Sphere instance | +| `QAS_TEST_TOKEN` | API token for authenticated API calls | +| `QAS_TEST_USERNAME` | Email for login endpoint (used by global setup) | +| `QAS_TEST_PASSWORD` | Password for login endpoint (used by global setup) | +| `QAS_DEV_AUTH` | (Optional) Dev auth cookie value for dev environments | + The `tsconfig.json` excludes `src/tests` from compilation output. ## Build diff --git a/README.md b/README.md index c8dbde7..b868549 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,29 @@ [![license](https://img.shields.io/npm/l/qas-cli)](https://github.com/Hypersequent/qas-cli/blob/main/LICENSE) [![CI](https://github.com/Hypersequent/qas-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Hypersequent/qas-cli/actions/workflows/ci.yml) +## Table of Contents + +- [Description](#description) +- [Installation](#installation) + - [Requirements](#requirements) + - [Via NPX](#via-npx) + - [Via NPM](#via-npm) +- [Shell Completion](#shell-completion) +- [Environment](#environment) +- [Command: `api`](#command-api) + - [API Command Tree](#api-command-tree) +- [Commands: `junit-upload`, `playwright-json-upload`, `allure-upload`](#commands-junit-upload-playwright-json-upload-allure-upload) + - [Options](#options) + - [Run Name Template Placeholders](#run-name-template-placeholders) + - [Usage Examples](#usage-examples) +- [Test Report Requirements](#test-report-requirements) + - [JUnit XML](#junit-xml) + - [Playwright JSON](#playwright-json) + - [Allure](#allure) +- [Run-Level Logs](#run-level-logs) +- [AI Agent Skill](#ai-agent-skill) +- [Development](#development-for-those-who-want-to-contribute-to-the-tool) + ## Description The QAS CLI is a command-line tool for submitting your test automation results to [QA Sphere](https://qasphere.com/). It provides the most efficient way to collect and report test results from your test automation workflow, CI/CD pipeline, and build servers. @@ -34,6 +57,24 @@ Verify installation: `qasphere --version` **Update:** Run `npm update -g qas-cli` to get the latest version. +## Shell Completion + +The CLI supports shell completion for commands and options. To enable it, append the completion script to your shell profile: + +**Zsh:** + +```bash +qasphere completion >> ~/.zshrc +``` + +**Bash:** + +```bash +qasphere completion >> ~/.bashrc +``` + +Then restart your shell or source the profile (e.g., `source ~/.zshrc`). After that, pressing `Tab` will autocomplete commands and options. + ## Environment The CLI requires the following variables to be defined: @@ -59,6 +100,70 @@ QAS_URL=https://qas.eu1.qasphere.com # QAS_URL=https://qas.eu1.qasphere.com ``` +## Command: `api` + +The `api` command provides direct access to the QA Sphere public API from the command line. Outputting JSON to stdout for easy scripting and piping. + +### API Command Tree + +``` +qasphere api [options] +``` + +``` +qasphere api +├── audit-logs +│ └── list # List audit log entries +├── custom-fields +│ └── list --project-code # List custom fields +├── files +│ └── upload --file # Upload a file attachment +├── folders +│ ├── list --project-code # List folders +│ └── bulk-create --project-code --folders # Create/update folders +├── milestones +│ ├── list --project-code # List milestones +│ └── create --project-code --title # Create milestone +├── projects +│ ├── list # List all projects +│ ├── get --project-code # Get project by code +│ └── create --code --title # Create project +├── requirements +│ └── list --project-code # List requirements +├── results +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ └── batch-create --project-code --run-id --items # Batch create results +├── runs +│ ├── create --project-code --title --type --query-plans # Create run +│ ├── list --project-code # List runs +│ ├── clone --project-code --run-id --title # Clone run +│ ├── close --project-code --run-id # Close run +│ └── tcases +│ ├── list --project-code --run-id # List test cases in run +│ └── get --project-code --run-id --tcase-id # Get test case in run +├── settings +│ ├── list-statuses # List result statuses +│ └── update-statuses --statuses # Update custom statuses +├── shared-preconditions +│ ├── list --project-code # List shared preconditions +│ └── get --project-code --id # Get shared precondition +├── shared-steps +│ ├── list --project-code # List shared steps +│ └── get --project-code --id # Get shared step +├── tags +│ └── list --project-code # List tags +├── test-cases +│ ├── list --project-code # List test cases +│ ├── get --project-code --tcase-id # Get test case +│ ├── count --project-code # Count test cases +│ ├── create --project-code --body # Create test case +│ └── update --project-code --tcase-id --body # Update test case +├── test-plans +│ └── create --project-code --body # Create test plan +└── users + └── list # List all users +``` + ## Commands: `junit-upload`, `playwright-json-upload`, `allure-upload` The `junit-upload`, `playwright-json-upload`, and `allure-upload` commands upload test results to QA Sphere. @@ -272,6 +377,16 @@ The CLI automatically detects global or suite-level failures and uploads them as - **Playwright JSON**: Top-level `errors` array entries (global setup/teardown failures) are extracted as run-level logs. - **Allure**: Failed or broken `befores`/`afters` fixtures in `*-container.json` files (e.g., session/module-level setup/teardown failures from pytest) are extracted as run-level logs. +## AI Agent Skill + +qas-cli includes a [SKILL.md](./SKILL.md) file that enables AI coding agents (e.g., Claude Code, Cursor) to use the CLI effectively. To add this skill to your agent: + +```bash +npx skills add Hypersequent/qas-cli +``` + +The skill provides the agent with full documentation of the CLI commands, options, and conventions. See [skills](https://github.com/vercel-labs/skills) for more details. + ## Development (for those who want to contribute to the tool) 1. Install and build: `npm install && npm run build && npm link` diff --git a/package-lock.json b/package-lock.json index b98862f..1a2117c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qas-cli", - "version": "0.4.6", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.4.6", + "version": "0.5.0", "license": "ISC", "dependencies": { "chalk": "^5.4.1", @@ -38,7 +38,7 @@ "ts-add-js-extension": "^1.6.6", "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", - "vitest": "^3.1.2" + "vitest": "^4.1.0" } }, "node_modules/@bundled-es-modules/cookie": { @@ -69,411 +69,46 @@ "tough-cookie": "^4.1.4" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", - "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", - "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", - "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", - "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", - "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", - "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", - "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", - "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", - "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", - "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", - "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", - "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", - "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", - "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", - "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", - "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", - "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", - "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", - "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", - "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", - "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", - "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", - "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "dev": true, + "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", - "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "dev": true, + "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", - "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, + "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -497,33 +132,63 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -532,19 +197,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -554,11 +220,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -566,31 +244,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { - "version": "9.25.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", - "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -733,10 +430,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "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/@mswjs/interceptors": { "version": "0.37.6", @@ -755,6 +453,23 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -812,265 +527,316 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", - "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", "dev": true, - "optional": true, - "os": [ - "android" - ] + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", - "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", - "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", - "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", - "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", - "cpu": [ - "arm64" ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", - "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", - "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", - "cpu": [ - "arm" ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", - "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", - "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", - "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", - "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", - "cpu": [ - "loong64" ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", - "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", - "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", - "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ - "riscv64" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", - "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ - "s390x" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", - "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", - "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ - "x64" + "wasm32" ], "dev": true, + "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", - "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", - "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ - "ia32" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", - "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, + "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/cookie": { "version": "0.6.0", @@ -1078,6 +844,13 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, + "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/escape-html": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz", @@ -1094,7 +867,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { "version": "20.17.32", @@ -1279,30 +1053,6 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.31.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", @@ -1356,65 +1106,44 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", - "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", - "dev": true, - "dependencies": { - "@vitest/spy": "3.1.2", - "@vitest/utils": "3.1.2", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", - "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", - "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, + "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", - "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.2", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -1422,13 +1151,15 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", - "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.2", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1436,36 +1167,36 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", - "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, - "dependencies": { - "tinyspy": "^3.0.2" - }, + "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", - "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.2", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1478,15 +1209,17 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1555,13 +1288,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "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" } @@ -1570,16 +1305,17 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -1594,38 +1330,24 @@ "node": ">=8" } }, - "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, - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -1639,15 +1361,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1802,7 +1515,15 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -1844,21 +1565,22 @@ } } }, - "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, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -1889,50 +1611,11 @@ } }, "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 - }, - "node_modules/esbuild": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", - "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.3", - "@esbuild/android-arm": "0.25.3", - "@esbuild/android-arm64": "0.25.3", - "@esbuild/android-x64": "0.25.3", - "@esbuild/darwin-arm64": "0.25.3", - "@esbuild/darwin-x64": "0.25.3", - "@esbuild/freebsd-arm64": "0.25.3", - "@esbuild/freebsd-x64": "0.25.3", - "@esbuild/linux-arm": "0.25.3", - "@esbuild/linux-arm64": "0.25.3", - "@esbuild/linux-ia32": "0.25.3", - "@esbuild/linux-loong64": "0.25.3", - "@esbuild/linux-mips64el": "0.25.3", - "@esbuild/linux-ppc64": "0.25.3", - "@esbuild/linux-riscv64": "0.25.3", - "@esbuild/linux-s390x": "0.25.3", - "@esbuild/linux-x64": "0.25.3", - "@esbuild/netbsd-arm64": "0.25.3", - "@esbuild/netbsd-x64": "0.25.3", - "@esbuild/openbsd-arm64": "0.25.3", - "@esbuild/openbsd-x64": "0.25.3", - "@esbuild/sunos-x64": "0.25.3", - "@esbuild/win32-arm64": "0.25.3", - "@esbuild/win32-ia32": "0.25.3", - "@esbuild/win32-x64": "0.25.3" - } + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", @@ -1960,32 +1643,32 @@ } }, "node_modules/eslint": { - "version": "9.25.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", - "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.25.1", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -1997,7 +1680,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2036,10 +1719,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2063,6 +1747,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2080,10 +1775,11 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2091,15 +1787,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2109,10 +1819,11 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2137,6 +1848,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2158,6 +1870,7 @@ "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" } @@ -2203,10 +1916,11 @@ } }, "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "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" } @@ -2215,7 +1929,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2249,7 +1964,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -2320,10 +2036,11 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2331,6 +2048,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2466,6 +2184,7 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2537,68 +2256,331 @@ "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "argparse": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -2862,19 +2844,14 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true - }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "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.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/merge-stream": { @@ -2933,15 +2910,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -3014,6 +2995,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3056,6 +3038,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -3130,6 +3123,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3165,16 +3159,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, - "engines": { - "node": ">= 14.16" - } + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -3208,9 +3194,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3226,8 +3212,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3326,6 +3313,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -3380,43 +3368,38 @@ "dev": true, "license": "MIT" }, - "node_modules/rollup": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", - "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.1", - "@rollup/rollup-android-arm64": "4.40.1", - "@rollup/rollup-darwin-arm64": "4.40.1", - "@rollup/rollup-darwin-x64": "4.40.1", - "@rollup/rollup-freebsd-arm64": "4.40.1", - "@rollup/rollup-freebsd-x64": "4.40.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", - "@rollup/rollup-linux-arm-musleabihf": "4.40.1", - "@rollup/rollup-linux-arm64-gnu": "4.40.1", - "@rollup/rollup-linux-arm64-musl": "4.40.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", - "@rollup/rollup-linux-riscv64-gnu": "4.40.1", - "@rollup/rollup-linux-riscv64-musl": "4.40.1", - "@rollup/rollup-linux-s390x-gnu": "4.40.1", - "@rollup/rollup-linux-x64-gnu": "4.40.1", - "@rollup/rollup-linux-x64-musl": "4.40.1", - "@rollup/rollup-win32-arm64-msvc": "4.40.1", - "@rollup/rollup-win32-ia32-msvc": "4.40.1", - "@rollup/rollup-win32-x64-msvc": "4.40.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, "node_modules/run-parallel": { @@ -3545,6 +3528,7 @@ "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" } @@ -3565,10 +3549,11 @@ } }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" }, "node_modules/strict-event-emitter": { "version": "0.5.1", @@ -3653,6 +3638,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -3679,19 +3665,24 @@ "dev": true }, "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 + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "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.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -3701,10 +3692,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "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" }, @@ -3715,10 +3710,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "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" }, @@ -3726,29 +3722,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "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, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -3804,6 +3783,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3883,6 +3870,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -3897,121 +3885,121 @@ "requires-port": "^1.0.0" } }, - "node_modules/vite": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", - "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite": "bin/vite.js" + "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { + "@edge-runtime/vm": { "optional": true }, - "less": { + "@opentelemetry/api": { "optional": true }, - "lightningcss": { + "@types/node": { "optional": true }, - "sass": { + "@vitest/browser-playwright": { "optional": true }, - "sass-embedded": { + "@vitest/browser-preview": { "optional": true }, - "stylus": { + "@vitest/browser-webdriverio": { "optional": true }, - "sugarss": { + "@vitest/ui": { "optional": true }, - "terser": { + "happy-dom": { "optional": true }, - "tsx": { + "jsdom": { "optional": true }, - "yaml": { - "optional": true + "vite": { + "optional": false } } }, - "node_modules/vite-node": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", - "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, + "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, + }, "peerDependencies": { - "picomatch": "^3 || ^4" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { - "picomatch": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "node_modules/vitest/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" }, @@ -4019,72 +4007,81 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", - "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", - "dev": true, - "dependencies": { - "@vitest/expect": "3.1.2", - "@vitest/mocker": "3.1.2", - "@vitest/pretty-format": "^3.1.2", - "@vitest/runner": "3.1.2", - "@vitest/snapshot": "3.1.2", - "@vitest/spy": "3.1.2", - "@vitest/utils": "3.1.2", - "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.13", - "tinypool": "^1.0.2", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.2", - "why-is-node-running": "^2.3.0" + "node_modules/vitest/node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", + "tinyglobby": "^0.2.15" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.2", - "@vitest/ui": "3.1.2", - "happy-dom": "*", - "jsdom": "*" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.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": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@types/debug": { + "@vitejs/devtools": { "optional": true }, - "@types/node": { + "esbuild": { "optional": true }, - "@vitest/browser": { + "jiti": { "optional": true }, - "@vitest/ui": { + "less": { "optional": true }, - "happy-dom": { + "sass": { "optional": true }, - "jsdom": { + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } diff --git a/package.json b/package.json index 4ca64d2..2fca9c1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "./build/bin/qasphere.js", "types": "./build/bin/qasphere.d.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --tagsFilter=!live", + "test:live": "vitest run --tagsFilter=live", "test:watch": "vitest", "build": "npm run clean && tsc && ts-add-js-extension --dir=./build && chmod +x build/bin/qasphere.js", "clean": "rm -rf ./build", @@ -55,7 +56,7 @@ "ts-add-js-extension": "^1.6.6", "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", - "vitest": "^3.1.2" + "vitest": "^4.1.0" }, "dependencies": { "chalk": "^5.4.1", diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md new file mode 100644 index 0000000..f1da74b --- /dev/null +++ b/skills/qas-cli/SKILL.md @@ -0,0 +1,147 @@ +--- +name: qas-cli +description: CLI tool for managing QA Sphere test cases, runs, and results. Upload test automation results and access the full QA Sphere public API from the command line. +metadata: + author: Hypersequent + version: '0.5.0' +--- + +# qas-cli + +CLI for [QA Sphere](https://qasphere.com/) — upload test automation results and access the full QA Sphere API from the command line. + +## Prerequisites + +- **Node.js** 18.0.0+ +- **`QAS_TOKEN`** — QA Sphere API token ([how to generate](https://docs.qasphere.com/api/authentication)) +- **`QAS_URL`** — Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) + +Set via environment variables, `.env`, or `.qaspherecli` file. + +## Upload Commands + +Upload test results to QA Sphere test runs. All three share the same options. + +```bash +qasphere junit-upload [options] +qasphere playwright-json-upload [options] +qasphere allure-upload [options] +``` + +Key options: `-r, --run-url`, `--project-code`, `--run-name`, `--create-tcases`, `--attachments`, `--force`, `--ignore-unmatched` + +### Test Case Matching + +Results are matched to QA Sphere test cases via markers: + +- **Hyphenated** (all formats): `PRJ-123` anywhere in test name +- **Underscore** (JUnit only): `test_prj123_login` — name must start with `test` +- **CamelCase** (JUnit only): `TestPrj123Login` — name must start with `Test` +- **Playwright annotations**: `{ type: "test case", description: "" }` +- **Allure TMS links**: `{ type: "tms", url: "" }` + +## API Command Tree + +``` +qasphere api [options] +``` + +All commands output JSON to stdout, errors to stderr. + +``` +qasphere api +├── audit-logs +│ └── list # List audit log entries +├── custom-fields +│ └── list --project-code # List custom fields +├── files +│ └── upload --file # Upload a file attachment +├── folders +│ ├── list --project-code # List folders +│ └── bulk-create --project-code --folders # Create/update folders +├── milestones +│ ├── list --project-code # List milestones +│ └── create --project-code --title # Create milestone +├── projects +│ ├── list # List all projects +│ ├── get --project-code # Get project by code +│ └── create --code --title # Create project +├── requirements +│ └── list --project-code # List requirements +├── results +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ └── batch-create --project-code --run-id --items # Batch create results +├── runs +│ ├── create --project-code --title --type --query-plans # Create run +│ ├── list --project-code # List runs +│ ├── clone --project-code --run-id --title # Clone run +│ ├── close --project-code --run-id # Close run +│ └── tcases +│ ├── list --project-code --run-id # List test cases in run +│ └── get --project-code --run-id --tcase-id # Get test case in run +├── settings +│ ├── list-statuses # List result statuses +│ └── update-statuses --statuses # Update custom statuses +├── shared-preconditions +│ ├── list --project-code # List shared preconditions +│ └── get --project-code --id # Get shared precondition +├── shared-steps +│ ├── list --project-code # List shared steps +│ └── get --project-code --id # Get shared step +├── tags +│ └── list --project-code # List tags +├── test-cases +│ ├── list --project-code # List test cases +│ ├── get --project-code --tcase-id # Get test case +│ ├── count --project-code # Count test cases +│ ├── create --project-code --body # Create test case +│ └── update --project-code --tcase-id --body # Update test case +├── test-plans +│ └── create --project-code --body # Create test plan +└── users + └── list # List all users +``` + +Each subcommand accepts `-h` option to show all available subcommands if any. +Use `qasphere api -h` to see the full command signature, all options, examples, and online documentation link. +When fetching the online documentation, the link URL can be appended with `.md` to view it in markdown. Use the markdown version first before falling back to the original URL if the endpoint returned a >= 400 status. + +## Common Workflows + +### Upload JUnit results to an existing run + +```bash +qasphere junit-upload -r https://qas.eu1.qasphere.com/project/PRJ/run/23 ./test-results.xml +``` + +### Create a run, upload results, and close it + +```bash +qasphere api runs create \ + --project-code PRJ --title "CI Build $BUILD_NUMBER" \ + --type static --query-plans '[{"tcaseIds": ["abc123", "def456"]}]' + +qasphere api results batch-create \ + --project-code PRJ --run-id 15 \ + --items '[{"tcaseId": "abc123", "status": "passed"}, {"tcaseId": "def456", "status": "failed", "comment": "Timeout"}]' + +qasphere api runs close --project-code PRJ --run-id 15 +``` + +### Bulk-create folders and test cases + +```bash +qasphere api folders bulk-create \ + --project-code PRJ \ + --folders '[{"path": ["Authentication", "Login"]}, {"path": ["Authentication", "OAuth"]}]' + +qasphere api test-cases create \ + --project-code PRJ \ + --body '{"title": "Login with valid credentials", "type": "standalone", "folderId": 1, "priority": "high"}' +``` + +## Important Notes + +- JSON args (`--body`, `--query-plans`, `--items`, `--folders`, `--statuses`, `--links`) accepts raw JSON strings or a file path `@path/to/file` to read from file relative to the current working directory. +- Use `--force` on upload commands to continue past invalid test cases or missing attachments. +- Use `--verbose` for stack traces on errors. diff --git a/src/api/audit-logs.ts b/src/api/audit-logs.ts new file mode 100644 index 0000000..7ab30bd --- /dev/null +++ b/src/api/audit-logs.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { appendSearchParams, jsonResponse, withJson } from './utils' +import { validateRequest } from './schemas' + +export interface AuditLogUser { + id: number + name: string + email: string +} + +export interface AuditLog { + id: number + user: AuditLogUser | null + action: string + ip: string + userAgent: string + createdAt: string + meta?: Record +} + +export const ListAuditLogsRequestSchema = z.object({ + after: z.number().int().nonnegative().optional(), + count: z.number().int().positive().optional(), +}) + +export type ListAuditLogsRequest = z.infer + +export interface ListAuditLogsResponse { + after: number + count: number + events: AuditLog[] +} + +export const createAuditLogApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (params?: ListAuditLogsRequest) => { + const validated = params ? validateRequest(params, ListAuditLogsRequestSchema) : {} + return fetcher(appendSearchParams(`/api/public/v0/audit-logs`, validated)).then((r) => + jsonResponse(r) + ) + }, + } +} diff --git a/src/api/custom-fields.ts b/src/api/custom-fields.ts new file mode 100644 index 0000000..802b91a --- /dev/null +++ b/src/api/custom-fields.ts @@ -0,0 +1,19 @@ +import { ResourceId } from './schemas' +import { jsonResponse, withJson } from './utils' + +export interface CustomField { + id: number + title: string + type: string + isActive: boolean +} + +export const createCustomFieldApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: (projectCode: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/custom-field`) + .then((r) => jsonResponse<{ customFields: CustomField[] }>(r)) + .then((r) => r.customFields), + } +} diff --git a/src/api/file.ts b/src/api/file.ts index f16f17f..0cbcc0d 100644 --- a/src/api/file.ts +++ b/src/api/file.ts @@ -1,7 +1,12 @@ import { jsonResponse } from './utils' +export interface RemoteFile { + id: string + url: string +} + export const createFileApi = (fetcher: typeof fetch) => ({ - uploadFiles: async (files: Array<{ blob: Blob; filename: string }>) => { + upload: async (files: Array<{ blob: Blob; filename: string }>) => { const form = new FormData() for (const { blob, filename } of files) { form.append('files', blob, filename) diff --git a/src/api/folders.ts b/src/api/folders.ts index 964dc54..82c2080 100644 --- a/src/api/folders.ts +++ b/src/api/folders.ts @@ -1,13 +1,66 @@ -import { Folder, GetFoldersRequest, PaginatedResponse, ResourceId } from './schemas' +import { z } from 'zod' +import { + PaginatedResponse, + ResourceId, + limitParam, + pageParam, + sortFieldParam, + sortOrderParam, + validateRequest, +} from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' +export interface Folder { + id: number + title: string + comment: string + pos: number + parentId: number + projectId: string +} + +export const GetFoldersRequestSchema = z.object({ + page: pageParam, + limit: limitParam, + search: z.string().optional(), + sortField: sortFieldParam, + sortOrder: sortOrderParam, +}) + +export type GetFoldersRequest = z.infer + +export const BulkCreateFoldersRequestSchema = z + .array( + z.object({ + path: z.array(z.string()).min(1, 'path must have at least one element'), + comment: z.string().optional(), + }) + ) + .min(1, 'Must contain at least one folder') + +export type BulkCreateFoldersRequest = z.infer + +export interface BulkCreateFoldersResponse { + ids: number[][] +} + export const createFolderApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { - getFoldersPaginated: (projectCode: ResourceId, request: GetFoldersRequest) => - fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, request) - ).then((r) => jsonResponse>(r)), + getPaginated: async (projectCode: ResourceId, request: GetFoldersRequest) => { + const validated = validateRequest(request, GetFoldersRequestSchema) + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, validated) + ).then((r) => jsonResponse>(r)) + }, + + bulkCreate: async (projectCode: ResourceId, req: BulkCreateFoldersRequest) => { + const validated = validateRequest(req, BulkCreateFoldersRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/folder/bulk`, { + method: 'POST', + body: JSON.stringify({ folders: validated }), + }).then((r) => jsonResponse(r)) + }, } } diff --git a/src/api/index.ts b/src/api/index.ts index 105d1ef..22565b8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,18 +1,40 @@ +import { createAuditLogApi } from './audit-logs' +import { createCustomFieldApi } from './custom-fields' import { createFileApi } from './file' import { createFolderApi } from './folders' +import { createMilestoneApi } from './milestones' import { createProjectApi } from './projects' -import { createRunApi } from './run' +import { createRequirementApi } from './requirements' +import { createResultApi } from './results' +import { createRunApi } from './runs' +import { createSettingsApi } from './settings' +import { createSharedPreconditionApi } from './shared-preconditions' +import { createSharedStepApi } from './shared-steps' +import { createTagApi } from './tags' import { createTCaseApi } from './tcases' -import { withBaseUrl, withHeaders, withHttpRetry } from './utils' +import { createTestPlanApi } from './test-plans' +import { createUserApi } from './users' +import { withBaseUrl, withDevAuth, withHeaders, withHttpRetry } from './utils' import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { return { + auditLogs: createAuditLogApi(fetcher), + customFields: createCustomFieldApi(fetcher), files: createFileApi(fetcher), folders: createFolderApi(fetcher), + milestones: createMilestoneApi(fetcher), projects: createProjectApi(fetcher), + requirements: createRequirementApi(fetcher), + results: createResultApi(fetcher), runs: createRunApi(fetcher), - testcases: createTCaseApi(fetcher), + settings: createSettingsApi(fetcher), + sharedPreconditions: createSharedPreconditionApi(fetcher), + sharedSteps: createSharedStepApi(fetcher), + tags: createTagApi(fetcher), + testCases: createTCaseApi(fetcher), + testPlans: createTestPlanApi(fetcher), + users: createUserApi(fetcher), } } @@ -21,9 +43,11 @@ export type Api = ReturnType export const createApi = (baseUrl: string, apiKey: string) => getApi( withHttpRetry( - withHeaders(withBaseUrl(fetch, baseUrl), { - Authorization: `ApiKey ${apiKey}`, - 'User-Agent': `qas-cli/${CLI_VERSION}`, - }) + withDevAuth( + withHeaders(withBaseUrl(fetch, baseUrl), { + Authorization: `ApiKey ${apiKey}`, + 'User-Agent': `qas-cli/${CLI_VERSION}`, + }) + ) ) ) diff --git a/src/api/milestones.ts b/src/api/milestones.ts new file mode 100644 index 0000000..ba63457 --- /dev/null +++ b/src/api/milestones.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { ResourceId, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface Milestone { + id: number + title: string + archived: boolean +} + +export const ListMilestonesRequestSchema = z.object({ + archived: z.boolean().optional(), +}) + +export type ListMilestonesRequest = z.infer + +export const CreateMilestoneRequestSchema = z.object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters'), +}) + +export type CreateMilestoneRequest = z.infer + +export const createMilestoneApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListMilestonesRequest) => { + const validated = params ? validateRequest(params, ListMilestonesRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/milestone`, validated) + ) + .then((r) => jsonResponse<{ milestones: Milestone[] }>(r)) + .then((r) => r.milestones) + }, + + create: async (projectCode: ResourceId, req: CreateMilestoneRequest) => { + const validated = validateRequest(req, CreateMilestoneRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/milestone`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: number }>(r)) + }, + } +} diff --git a/src/api/projects.ts b/src/api/projects.ts index 4236b7d..155a0ab 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -1,6 +1,71 @@ -export const createProjectApi = (fetcher: typeof fetch) => ({ - checkProjectExists: async (project: string) => { - const res = await fetcher(`/api/public/v0/project/${project}`) - return res.ok - }, +import { z } from 'zod' +import { ResourceId, validateRequest } from './schemas' +import { jsonResponse, withJson } from './utils' + +export interface ProjectLink { + text: string + url: string +} + +export interface Project { + id: string + code: string + title: string + description: string + overviewTitle: string + overviewDescription: string + links: ProjectLink[] + createdAt: string + updatedAt: string + archivedAt: string | null +} + +export const projectLinksSchema = z.array( + z.object({ + text: z.string().max(255, 'link text must be at most 255 characters'), + url: z.string().url().max(255, 'link url must be at most 255 characters'), + }) +) + +export const CreateProjectRequestSchema = z.object({ + code: z + .string() + .min(2, 'code must be at least 2 characters') + .max(5, 'code must be at most 5 characters') + .regex(/^[a-zA-Z0-9]+$/, 'code must contain only alphanumeric characters'), + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters'), + links: projectLinksSchema.optional(), + overviewTitle: z.string().max(255, 'overviewTitle must be at most 255 characters').optional(), + overviewDescription: z.string().optional(), }) + +export type CreateProjectRequest = z.infer + +export const createProjectApi = (fetcher: typeof fetch) => { + const jsonFetcher = withJson(fetcher) + return { + checkExists: async (project: string) => { + const res = await fetcher(`/api/public/v0/project/${project}`) + return res.ok + }, + + list: () => + jsonFetcher(`/api/public/v0/project`) + .then((r) => jsonResponse<{ projects: Project[] | null }>(r)) + .then((r) => r.projects ?? []), + + get: (codeOrId: ResourceId) => + jsonFetcher(`/api/public/v0/project/${codeOrId}`).then((r) => jsonResponse(r)), + + create: async (req: CreateProjectRequest) => { + const validated = validateRequest(req, CreateProjectRequestSchema) + return jsonFetcher(`/api/public/v0/project`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: string }>(r)) + }, + } +} diff --git a/src/api/requirements.ts b/src/api/requirements.ts new file mode 100644 index 0000000..1a8db91 --- /dev/null +++ b/src/api/requirements.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface Requirement { + id: number + text: string + url: string + tcaseCount?: number +} + +export const ListRequirementsRequestSchema = z.object({ + sortField: sortFieldParam, + sortOrder: sortOrderParam, + include: z.string().optional(), +}) + +export type ListRequirementsRequest = z.infer + +export const createRequirementApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListRequirementsRequest) => { + const validated = params ? validateRequest(params, ListRequirementsRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/requirement`, validated) + ) + .then((r) => jsonResponse<{ requirements: Requirement[] }>(r)) + .then((r) => r.requirements) + }, + } +} diff --git a/src/api/results.ts b/src/api/results.ts new file mode 100644 index 0000000..00f1724 --- /dev/null +++ b/src/api/results.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { ResourceId, validateRequest } from './schemas' +import { jsonResponse, withJson } from './utils' + +const resultStatusEnum = z.enum([ + 'passed', + 'failed', + 'blocked', + 'skipped', + 'open', + 'custom1', + 'custom2', + 'custom3', + 'custom4', +]) + +export const resultLinksSchema = z.array( + z.object({ + text: z.string(), + url: z.string().url(), + }) +) + +export const CreateResultsRequestItemSchema = z.object({ + tcaseId: z.string(), + status: resultStatusEnum, + comment: z.string().optional(), + timeTaken: z.number().int().nonnegative().nullable().optional(), + links: resultLinksSchema.optional(), +}) + +export type CreateResultsRequestItem = z.infer + +export const CreateResultsRequestSchema = z.object({ + items: z.array(CreateResultsRequestItemSchema).min(1, 'Must contain at least one result item'), +}) + +export type CreateResultsRequest = z.infer + +export const CreateResultRequestSchema = z.object({ + status: resultStatusEnum, + comment: z.string().optional(), + timeTaken: z.number().int().nonnegative().nullable().optional(), + links: resultLinksSchema.optional(), +}) + +export type CreateResultRequest = z.infer + +export interface CreateResultResponse { + id: number +} + +export const createResultApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + createBatch: async (projectCode: ResourceId, runId: ResourceId, req: CreateResultsRequest) => { + const validated = validateRequest(req, CreateResultsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/result/batch`, { + body: JSON.stringify(validated), + method: 'POST', + }).then((r) => jsonResponse<{ ids: number[] }>(r)) + }, + + create: async ( + projectCode: ResourceId, + runId: ResourceId, + tcaseId: ResourceId, + req: CreateResultRequest + ) => { + const validated = validateRequest(req, CreateResultRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}/result`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + } +} diff --git a/src/api/run.ts b/src/api/run.ts deleted file mode 100644 index 9673007..0000000 --- a/src/api/run.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { CreateResultsRequest, ResourceId, RunTCase } from './schemas' -import { jsonResponse, withJson } from './utils' - -export interface CreateRunRequest { - title: string - description?: string - type: 'static' | 'static_struct' | 'live' - queryPlans: Array<{ - tcaseIds?: string[] - folderIds?: number[] - tagIds?: number[] - priorities?: string[] - }> -} - -export interface CreateRunResponse { - id: number -} - -export interface CreateRunLogRequest { - comment: string -} - -export const createRunApi = (fetcher: typeof fetch) => { - fetcher = withJson(fetcher) - return { - getRunTCases: (projectCode: ResourceId, runId: ResourceId) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`) - .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) - .then((r) => r.tcases), - - createResults: (projectCode: ResourceId, runId: ResourceId, req: CreateResultsRequest) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/result/batch`, { - body: JSON.stringify(req), - method: 'POST', - }).then((r) => jsonResponse<{ ids: number[] }>(r)), - - createRun: (projectCode: ResourceId, req: CreateRunRequest) => - fetcher(`/api/public/v0/project/${projectCode}/run`, { - method: 'POST', - body: JSON.stringify(req), - }).then((r) => jsonResponse(r)), - - createRunLog: (projectCode: ResourceId, runId: ResourceId, req: CreateRunLogRequest) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/log`, { - method: 'POST', - body: JSON.stringify(req), - }).then((r) => jsonResponse<{ id: string }>(r)), - } -} - -export type RunApi = ReturnType diff --git a/src/api/runs.ts b/src/api/runs.ts new file mode 100644 index 0000000..893063f --- /dev/null +++ b/src/api/runs.ts @@ -0,0 +1,218 @@ +import { z } from 'zod' +import { + MessageResponse, + ResourceId, + limitParam, + priorityEnum, + sortFieldParam, + sortOrderParam, + validateRequest, +} from './schemas' +import { Folder } from './folders' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface RunTCase { + id: string + version: number + folderId: number + pos: number + seq: number + title: string + priority: string + status: string + folder: Folder +} + +export const QueryPlanSchema = z + .object({ + tcaseIds: z.array(z.string()).optional(), + folderIds: z.array(z.number().int().positive()).optional(), + tagIds: z.array(z.number().int().positive()).optional(), + priorities: z.array(z.enum(['low', 'medium', 'high'])).optional(), + }) + .strict() + .refine((plan) => Object.values(plan).some((v) => v !== undefined), { + message: + 'Each query plan must specify at least one filter (tcaseIds, folderIds, tagIds, or priorities)', + }) + +export const QueryPlansSchema = z.array(QueryPlanSchema).min(1, { + message: + 'Must contain at least one query plan. Each plan selects test cases to include in the run. ' + + 'Example: [{"tcaseIds": ["abc123"]}]', +}) + +function validateQueryPlans( + run: { type: string; queryPlans: z.infer[] }, + ctx: z.RefinementCtx +) { + if (run.type !== 'live' && run.queryPlans.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['queryPlans'], + message: `Run type "${run.type}" supports exactly one query plan, but ${run.queryPlans.length} were provided. Only "live" runs support multiple query plans.`, + }) + } + if (run.type === 'live') { + run.queryPlans.forEach((plan, i) => { + if (plan.tcaseIds !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['queryPlans', i, 'tcaseIds'], + message: + 'tcaseIds is not allowed for "live" runs. Live runs only support filter-based selection (folderIds, tagIds, priorities).', + }) + } + }) + } +} + +export const CreateRunRequestSchema = z + .object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters'), + description: z.string().max(512, 'description must be at most 512 characters').optional(), + type: z.enum(['static', 'static_struct', 'live']), + milestoneId: z.number().int().positive().optional(), + configurationId: z.string().optional(), + assignmentId: z.number().int().positive().optional(), + queryPlans: QueryPlansSchema, + }) + .superRefine(validateQueryPlans) + +export type CreateRunRequest = z.infer + +export interface CreateRunResponse { + id: number +} + +export const ListRunsRequestSchema = z.object({ + closed: z.boolean().optional(), + milestoneIds: z.array(z.number().int().positive()).optional(), + limit: limitParam, +}) + +export type ListRunsRequest = z.infer + +export interface Run { + id: number + title: string + description?: string + type: string + closed: boolean +} + +export const CreateRunLogRequestSchema = z.object({ + comment: z.string(), +}) + +export type CreateRunLogRequest = z.infer + +export const CloneRunRequestSchema = z.object({ + runId: z.number().int().positive('runId must be a positive integer'), + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters'), + description: z.string().max(512, 'description must be at most 512 characters').optional(), + milestoneId: z.number().int().positive().optional(), + assignmentId: z.number().int().positive().optional(), +}) + +export type CloneRunRequest = z.infer + +export interface CloneRunResponse { + id: number +} + +export const ListRunTCasesRequestSchema = z.object({ + search: z.string().optional(), + tags: z.array(z.number().int().positive()).optional(), + priorities: z.array(priorityEnum).optional(), + include: z.string().optional(), + sortField: sortFieldParam, + sortOrder: sortOrderParam, +}) + +export type ListRunTCasesRequest = z.infer + +/** + * Schema for run objects within a test plan (no milestoneId). + * Used by test-plans create command. + */ +export const RunSchema = z + .object({ + title: z.string().min(1).max(255), + description: z.string().optional(), + type: z.enum(['static', 'static_struct', 'live']), + configurationId: z.string().optional(), + assignmentId: z.number().int().positive().optional(), + queryPlans: z.array(QueryPlanSchema).min(1), + }) + .superRefine(validateQueryPlans) + +export const createRunApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + getTCases: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`) + .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) + .then((r) => r.tcases), + + create: async (projectCode: ResourceId, req: CreateRunRequest) => { + const validated = validateRequest(req, CreateRunRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + list: async (projectCode: ResourceId, params?: ListRunsRequest) => { + const validated = params ? validateRequest(params, ListRunsRequestSchema) : {} + return fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/run`, validated)) + .then((r) => jsonResponse<{ runs: Run[] }>(r)) + .then((r) => r.runs) + }, + + clone: async (projectCode: ResourceId, req: CloneRunRequest) => { + const validated = validateRequest(req, CloneRunRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/clone`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + close: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/close`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + createLog: async (projectCode: ResourceId, runId: ResourceId, req: CreateRunLogRequest) => { + const validated = validateRequest(req, CreateRunLogRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/log`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: string }>(r)) + }, + + listTCases: async ( + projectCode: ResourceId, + runId: ResourceId, + params?: ListRunTCasesRequest + ) => { + const validated = params ? validateRequest(params, ListRunTCasesRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`, validated) + ) + .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) + .then((r) => r.tcases) + }, + + getTCase: (projectCode: ResourceId, runId: ResourceId, tcaseId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}`).then((r) => + jsonResponse(r) + ), + } +} diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 61b0425..9d7d9bc 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -1,6 +1,17 @@ +import { z, ZodError, ZodType } from 'zod' + export type ResourceId = string | number -export type ResultStatus = 'open' | 'passed' | 'blocked' | 'failed' | 'skipped' +export type ResultStatus = + | 'open' + | 'passed' + | 'blocked' + | 'failed' + | 'skipped' + | 'custom1' + | 'custom2' + | 'custom3' + | 'custom4' export interface PaginatedResponse { data: T[] @@ -13,66 +24,35 @@ export interface PaginatedRequest { page?: number limit?: number } - -export interface TCase { - id: string - legacyId?: string - seq: number - title: string - version: number - projectId: string - folderId: number -} - -export interface CreateTCasesRequest { - folderPath: string[] - tcases: { title: string; tags: string[] }[] -} - -export interface CreateTCasesResponse { - tcases: { id: string; seq: number }[] -} - -export interface GetTCasesRequest extends PaginatedRequest { - folders?: number[] -} - -export interface GetTCasesBySeqRequest { - seqIds: string[] - page?: number - limit?: number -} - -export interface GetFoldersRequest extends PaginatedRequest { - search?: string -} - -export interface Folder { - id: number - parentId: number - pos: number - title: string -} - -export interface RunTCase { - id: string - version: number - folderId: number - pos: number - seq: number - title: string - priority: string - status: string - folder: Folder -} - -export interface CreateResultsRequestItem { - tcaseId: string - status: ResultStatus - comment?: string - timeTaken: number | null // In milliseconds -} - -export interface CreateResultsRequest { - items: CreateResultsRequestItem[] -} +export interface MessageResponse { + message: string +} + +export class RequestValidationError extends Error { + constructor( + public readonly zodError: ZodError, + public readonly rawValue: unknown + ) { + super(zodError.message) + this.name = 'RequestValidationError' + } +} + +export function validateRequest(value: unknown, schema: ZodType): T { + try { + return schema.parse(value) + } catch (e) { + if (e instanceof ZodError) { + throw new RequestValidationError(e, value) + } + throw e + } +} + +export const sortFieldParam = z.string().optional() +export const sortOrderParam = z.enum(['asc', 'desc']).optional() +export type SortOrder = z.infer +export const pageParam = z.number().int().positive().optional() +export const limitParam = z.number().int().positive().optional() +export const priorityEnum = z.enum(['low', 'medium', 'high']) +export const tcaseTypeEnum = z.enum(['standalone', 'template', 'filled']) diff --git a/src/api/settings.ts b/src/api/settings.ts new file mode 100644 index 0000000..47083ab --- /dev/null +++ b/src/api/settings.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { jsonResponse, withJson } from './utils' +import { validateRequest } from './schemas' + +export const STATUS_COLORS = [ + 'blue', + 'gray', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'indigo', + 'purple', + 'pink', +] as const + +export const CUSTOM_STATUS_IDS = ['custom1', 'custom2', 'custom3', 'custom4'] as const + +export interface Status { + id: string + name: string + color: string + isActive: boolean +} + +export const UpdateStatusesRequestSchema = z.object({ + statuses: z.array( + z.object({ + id: z.enum(CUSTOM_STATUS_IDS), + name: z.string().min(1, 'name must not be empty'), + color: z.enum(STATUS_COLORS), + isActive: z.boolean(), + }) + ), +}) + +export type UpdateStatusesRequest = z.infer + +export const createSettingsApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: () => + fetcher(`/api/public/v0/settings/preferences/status`) + .then((r) => jsonResponse<{ statuses: Status[] }>(r)) + .then((r) => r.statuses), + + update: async (req: UpdateStatusesRequest) => { + const validated = validateRequest(req, UpdateStatusesRequestSchema) + return fetcher(`/api/public/v0/settings/preferences/status`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ message: string }>(r)) + }, + } +} diff --git a/src/api/shared-preconditions.ts b/src/api/shared-preconditions.ts new file mode 100644 index 0000000..ab78720 --- /dev/null +++ b/src/api/shared-preconditions.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface SharedPrecondition { + id: number + title: string + tcaseCount?: number +} + +export const ListSharedPreconditionsRequestSchema = z.object({ + sortField: sortFieldParam, + sortOrder: sortOrderParam, + include: z.string().optional(), +}) + +export type ListSharedPreconditionsRequest = z.infer + +export const createSharedPreconditionApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListSharedPreconditionsRequest) => { + const validated = params ? validateRequest(params, ListSharedPreconditionsRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/shared-precondition`, validated) + ).then((r) => jsonResponse(r)) + }, + + get: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/shared-precondition/${id}`).then((r) => + jsonResponse(r) + ), + } +} diff --git a/src/api/shared-steps.ts b/src/api/shared-steps.ts new file mode 100644 index 0000000..05ee6a3 --- /dev/null +++ b/src/api/shared-steps.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface SharedStep { + id: number + title: string + tcaseCount?: number +} + +export const ListSharedStepsRequestSchema = z.object({ + sortField: sortFieldParam, + sortOrder: sortOrderParam, + include: z.string().optional(), +}) + +export type ListSharedStepsRequest = z.infer + +export const createSharedStepApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListSharedStepsRequest) => { + const validated = params ? validateRequest(params, ListSharedStepsRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/shared-step`, validated) + ) + .then((r) => jsonResponse<{ sharedSteps: SharedStep[] }>(r)) + .then((r) => r.sharedSteps) + }, + + get: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/shared-step/${id}`).then((r) => + jsonResponse(r) + ), + } +} diff --git a/src/api/tags.ts b/src/api/tags.ts new file mode 100644 index 0000000..8b2408a --- /dev/null +++ b/src/api/tags.ts @@ -0,0 +1,17 @@ +import { ResourceId } from './schemas' +import { jsonResponse, withJson } from './utils' + +export interface Tag { + id: number + title: string +} + +export const createTagApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: (projectCode: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/tag`) + .then((r) => jsonResponse<{ tags: Tag[] }>(r)) + .then((r) => r.tags), + } +} diff --git a/src/api/tcases.ts b/src/api/tcases.ts index 8c758c3..564a3d8 100644 --- a/src/api/tcases.ts +++ b/src/api/tcases.ts @@ -1,32 +1,311 @@ +import { z } from 'zod' import { - CreateTCasesRequest, - CreateTCasesResponse, - GetTCasesBySeqRequest, - GetTCasesRequest, PaginatedResponse, ResourceId, - TCase, + limitParam, + pageParam, + priorityEnum, + sortFieldParam, + sortOrderParam, + tcaseTypeEnum, + validateRequest, } from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' +import type { Folder } from './folders' +import type { Project, ProjectLink } from './projects' +import type { Tag } from './tags' + +export interface TCaseFile { + id: string + fileName: string + mimeType: string + size: number + url?: string +} + +export interface TCaseRequirement { + id: string + text: string + url: string +} + +export interface TCaseSubStep { + id: number + type: string + version: number + isLatest: boolean + description?: string + expected?: string + deletedAt?: string +} + +export interface TCaseStep { + id: number + type: string + version: number + isLatest: boolean + title?: string + subSteps?: TCaseSubStep[] + description?: string + expected?: string + deletedAt?: string +} + +export interface TCasePrecondition { + projectId: string + id: number + version: number + title?: string + type: string + text: string + isLatest: boolean + createdAt: string + updatedAt: string + deletedAt?: string +} + +export interface TCaseParameterValues { + tcaseId: string + tcaseVersion: number + values: Record +} + +export interface TCase { + id: string + legacyId: string + version: number + type: string + title: string + seq: number + folderId: number + pos: number + priority: string + comment: string + precondition: TCasePrecondition + files: TCaseFile[] + links: ProjectLink[] + authorId: number + isDraft: boolean + isLatestVersion: boolean + isEmpty: boolean + templateTCaseId?: string + numFilledTCases?: number + createdAt: string + updatedAt: string + + // Fields included via `include` parameter or in get response + customFields?: Record + tags?: Tag[] + steps?: TCaseStep[] + requirements?: TCaseRequirement[] + parameterValues?: TCaseParameterValues + folder?: Folder + path?: Folder[] + project?: Project +} + +export const CreateTCasesBatchRequestSchema = z.object({ + folderPath: z.array(z.string()), + tcases: z.array(z.object({ title: z.string(), tags: z.array(z.string()) })), +}) + +export type CreateTCasesRequest = z.infer + +export interface CreateTCasesResponse { + tcases: { id: string; seq: number }[] +} + +export const GetTCasesRequestSchema = z.object({ + page: pageParam, + limit: limitParam, + folders: z.array(z.number().int().positive()).optional(), +}) + +export type GetTCasesRequest = z.infer + +export const GetTCasesBySeqRequestSchema = z.object({ + seqIds: z.array(z.string()), + page: pageParam, + limit: limitParam, +}) + +export type GetTCasesBySeqRequest = z.infer + +export const ListTCasesRequestSchema = z.object({ + page: pageParam, + limit: limitParam, + folders: z.array(z.number().int().positive()).optional(), + tags: z.array(z.number().int().positive()).optional(), + priorities: z.array(priorityEnum).optional(), + search: z.string().optional(), + types: z.array(tcaseTypeEnum).optional(), + draft: z.boolean().optional(), + sortField: sortFieldParam, + sortOrder: sortOrderParam, + include: z.array(z.string()).optional(), +}) + +export type ListTCasesRequest = z.infer + +export const CountTCasesRequestSchema = z.object({ + folders: z.array(z.number().int().positive()).optional(), + recursive: z.boolean().optional(), + tags: z.array(z.number().int().positive()).optional(), + priorities: z.array(priorityEnum).optional(), + draft: z.boolean().optional(), +}) + +export type CountTCasesRequest = z.infer + +const preconditionSchema = z.union([ + z.object({ text: z.string() }), + z.object({ sharedPreconditionId: z.number().int().positive() }), +]) + +const stepSchema = z.object({ + description: z.string().optional(), + expected: z.string().optional(), + sharedStepId: z.number().int().positive().optional(), +}) + +export const StepsArraySchema = z.array(stepSchema) + +const customFieldValueSchema = z.object({ + isDefault: z.boolean(), + value: z.string().optional(), +}) + +export const customFieldsSchema = z.record(z.string(), customFieldValueSchema) + +export const parameterValueSchema = z.object({ + values: z.record(z.string(), z.string()), +}) + +export const parameterValueWithIdSchema = z.object({ + tcaseId: z.string().optional(), + values: z.record(z.string(), z.string()), +}) + +export const CreateTCaseRequestSchema = z + .object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(511, 'title must be at most 511 characters'), + type: z.enum(['standalone', 'template']), + folderId: z.number().int().positive('folderId must be a positive integer'), + priority: z.enum(['low', 'medium', 'high']), + comment: z.string().optional(), + tags: z.array(z.string()).optional(), + isDraft: z.boolean().optional(), + steps: z.array(stepSchema).optional(), + precondition: preconditionSchema.optional(), + pos: z.number().int().nonnegative().optional(), + requirements: z.array(z.object({ text: z.string(), url: z.string().max(255) })).optional(), + links: z.array(z.object({ text: z.string(), url: z.string() })).optional(), + customFields: customFieldsSchema.optional(), + parameterValues: z.array(parameterValueSchema).optional(), + filledTCaseTitleSuffixParams: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + if (data.type !== 'template' && data.parameterValues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['parameterValues'], + message: 'parameterValues is only allowed for "template" test cases', + }) + } + if (data.type !== 'template' && data.filledTCaseTitleSuffixParams) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['filledTCaseTitleSuffixParams'], + message: 'filledTCaseTitleSuffixParams is only allowed for "template" test cases', + }) + } + }) + +export type CreateTCaseRequest = z.infer + +export const UpdateTCaseRequestSchema = z.object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(511, 'title must be at most 511 characters') + .optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + comment: z.string().optional(), + tags: z.array(z.string()).optional(), + isDraft: z.boolean().optional(), + steps: z.array(stepSchema).optional(), + precondition: preconditionSchema.optional(), + requirements: z.array(z.object({ text: z.string(), url: z.string().max(255) })).optional(), + links: z.array(z.object({ text: z.string(), url: z.string() })).optional(), + customFields: customFieldsSchema.optional(), + parameterValues: z.array(parameterValueWithIdSchema).optional(), + filledTCaseTitleSuffixParams: z.array(z.string()).optional(), +}) + +export type UpdateTCaseRequest = z.infer export const createTCaseApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { - getTCasesPaginated: (projectCode: ResourceId, request: GetTCasesRequest) => - fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, request)).then( - (r) => jsonResponse>(r) - ), + getPaginated: async (projectCode: ResourceId, request: GetTCasesRequest) => { + const validated = validateRequest(request, GetTCasesRequestSchema) + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, validated) + ).then((r) => jsonResponse>(r)) + }, - getTCasesBySeq: (projectCode: ResourceId, request: GetTCasesBySeqRequest) => - fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, { + getBySeq: async (projectCode: ResourceId, request: GetTCasesBySeqRequest) => { + const validated = validateRequest(request, GetTCasesBySeqRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, { method: 'POST', - body: JSON.stringify(request), - }).then((r) => jsonResponse>(r)), + body: JSON.stringify(validated), + }).then((r) => jsonResponse>(r)) + }, - createTCases: (projectCode: ResourceId, request: CreateTCasesRequest) => - fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, { + createBatch: async (projectCode: ResourceId, request: CreateTCasesRequest) => { + const validated = validateRequest(request, CreateTCasesBatchRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, { method: 'POST', - body: JSON.stringify(request), - }).then((r) => jsonResponse(r)), + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + list: async (projectCode: ResourceId, params?: ListTCasesRequest) => { + const validated = params ? validateRequest(params, ListTCasesRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, validated) + ).then((r) => jsonResponse>(r)) + }, + + get: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/tcase/${id}`).then((r) => + jsonResponse(r) + ), + + count: async (projectCode: ResourceId, params?: CountTCasesRequest) => { + const validated = params ? validateRequest(params, CountTCasesRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/count`, validated) + ).then((r) => jsonResponse<{ count: number }>(r)) + }, + + create: async (projectCode: ResourceId, req: CreateTCaseRequest) => { + const validated = validateRequest(req, CreateTCaseRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: string; seq: number }>(r)) + }, + + update: async (projectCode: ResourceId, id: ResourceId, req: UpdateTCaseRequest) => { + const validated = validateRequest(req, UpdateTCaseRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/${id}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ message: string }>(r)) + }, } } diff --git a/src/api/test-plans.ts b/src/api/test-plans.ts new file mode 100644 index 0000000..137aaaa --- /dev/null +++ b/src/api/test-plans.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { ResourceId, validateRequest } from './schemas' +import { RunSchema } from './runs' +import { jsonResponse, withJson } from './utils' + +export const CreateTestPlanRequestSchema = z.object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters'), + description: z.string().optional(), + milestoneId: z.number().int().positive().optional(), + runs: z.array(RunSchema).min(1, 'Must contain at least one run'), +}) + +export type CreateTestPlanRequest = z.infer + +export interface CreateTestPlanResponse { + id: number +} + +export const createTestPlanApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + create: async (projectCode: ResourceId, req: CreateTestPlanRequest) => { + const validated = validateRequest(req, CreateTestPlanRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/plan`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + } +} diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 0000000..2b34808 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,18 @@ +import { jsonResponse, withJson } from './utils' + +export interface User { + id: number + email: string + name: string + role: string +} + +export const createUserApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: () => + fetcher(`/api/public/v0/users`) + .then((r) => jsonResponse<{ users: User[] }>(r)) + .then((r) => r.users), + } +} diff --git a/src/api/utils.ts b/src/api/utils.ts index c114137..740e155 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,7 +1,8 @@ export const withBaseUrl = (fetcher: typeof fetch, baseUrl: string): typeof fetch => { + const normalizedBase = baseUrl.replace(/\/+$/, '') return (input: URL | RequestInfo, init?: RequestInit | undefined) => { if (typeof input === 'string') { - return fetcher(baseUrl + input, init) + return fetcher(normalizedBase + input, init) } return fetcher(input, init) } @@ -26,6 +27,24 @@ export const withJson = (fetcher: typeof fetch): typeof fetch => { } } +export const withDevAuth = (fetcher: typeof fetch): typeof fetch => { + const devAuth = process.env.QAS_DEV_AUTH + if (!devAuth) return fetcher + + return (input: URL | RequestInfo, init?: RequestInit | undefined) => { + const prev = (init?.headers as Record | undefined) ?? {} + const existing = prev['Cookie'] + const cookie = existing ? `${existing}; _devauth=${devAuth}` : `_devauth=${devAuth}` + return fetcher(input, { + ...init, + headers: { + ...prev, + Cookie: cookie, + }, + }) + } +} + export const withHeaders = ( fetcher: typeof fetch, headers: Record @@ -46,7 +65,12 @@ export const jsonResponse = async (response: Response): Promise => { if (response.ok) { return json as T } - if (typeof json === 'object' && 'message' in json && typeof json.message === 'string') { + if ( + json !== null && + typeof json === 'object' && + 'message' in json && + typeof json.message === 'string' + ) { throw new Error(json.message) } throw new Error(response.statusText) diff --git a/src/commands/api/audit-logs/command.ts b/src/commands/api/audit-logs/command.ts new file mode 100644 index 0000000..0861bcf --- /dev/null +++ b/src/commands/api/audit-logs/command.ts @@ -0,0 +1,40 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, buildArgumentMap, handleValidationError, printJson } from '../utils' +import help from './help' + +interface AuditLogsListArgs { + after?: number + count?: number +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + after: { + type: 'number', + describe: help.list.after, + }, + count: { + type: 'number', + describe: help.list.count, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.auditLogs + .list(args) + .catch(handleValidationError(buildArgumentMap(['after', 'count']))) + printJson(result) + }), +} + +export const auditLogsCommand: CommandModule = { + command: 'audit-logs', + describe: 'View audit logs', + builder: (yargs: Argv) => yargs.command(listCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/audit-logs/help.ts b/src/commands/api/audit-logs/help.ts new file mode 100644 index 0000000..5f68649 --- /dev/null +++ b/src/commands/api/audit-logs/help.ts @@ -0,0 +1,10 @@ +import { apiDocsEpilog } from '../utils' + +export default { + list: { + command: 'List audit log entries.', + after: 'Cursor for pagination. Returns events with ID greater than this value.', + count: 'Number of events to return per page. Must be between 1 and 1000. Omit to use default.', + epilog: apiDocsEpilog('audit_logs', 'list-audit-logs'), + }, +} as const diff --git a/src/commands/api/custom-fields/command.ts b/src/commands/api/custom-fields/command.ts new file mode 100644 index 0000000..e5058e2 --- /dev/null +++ b/src/commands/api/custom-fields/command.ts @@ -0,0 +1,34 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, printJson } from '../utils' +import help from './help' + +interface CustomFieldsListArgs { + 'project-code': string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.customFields.list(args['project-code']) + printJson(result) + }), +} + +export const customFieldsCommand: CommandModule = { + command: 'custom-fields', + describe: 'Manage custom fields', + builder: (yargs: Argv) => yargs.command(listCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/custom-fields/help.ts b/src/commands/api/custom-fields/help.ts new file mode 100644 index 0000000..31b2db7 --- /dev/null +++ b/src/commands/api/custom-fields/help.ts @@ -0,0 +1,11 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List all custom fields in a project.', + epilog: apiDocsEpilog('tcases_custom_fields', 'list-project-custom-fields'), + }, +} as const diff --git a/src/commands/api/files/command.ts b/src/commands/api/files/command.ts new file mode 100644 index 0000000..9f18a7d --- /dev/null +++ b/src/commands/api/files/command.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' +import { Argv, CommandModule } from 'yargs' +import { apiHandler, printJson } from '../utils' +import help from './help' + +interface FilesUploadArgs { + file: string +} + +const uploadCommand: CommandModule = { + command: 'upload', + describe: help.upload.command, + builder: (yargs: Argv) => + yargs + .options({ + file: { + type: 'string', + demandOption: true, + describe: help.upload.file, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.upload.epilog), + handler: apiHandler(async (args, connectApi) => { + const fileContent = readFileSync(args.file) + const filename = basename(args.file) + const blob = new Blob([fileContent]) + + const api = connectApi() + const [result] = await api.files.upload([{ blob, filename }]) + printJson(result) + }), +} + +export const filesCommand: CommandModule = { + command: 'files', + describe: 'Manage file attachments', + builder: (yargs: Argv) => yargs.command(uploadCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/files/help.ts b/src/commands/api/files/help.ts new file mode 100644 index 0000000..c5b7991 --- /dev/null +++ b/src/commands/api/files/help.ts @@ -0,0 +1,16 @@ +import { apiDocsEpilog } from '../utils' + +export default { + upload: { + command: 'Upload a file attachment.', + file: `Path to the file to upload.`, + epilog: apiDocsEpilog('upload_file', 'upload-file'), + }, + + examples: [ + { + usage: '$0 api files upload --file ./screenshot.png', + description: 'Upload a file attachment', + }, + ], +} as const diff --git a/src/commands/api/folders/command.ts b/src/commands/api/folders/command.ts new file mode 100644 index 0000000..53a79a4 --- /dev/null +++ b/src/commands/api/folders/command.ts @@ -0,0 +1,109 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseAndValidateJsonArg, + printJson, + type SortOrder, +} from '../utils' +import { BulkCreateFoldersRequestSchema } from '../../../api/folders' +import help from './help' + +interface FoldersListArgs { + 'project-code': string + page?: number + limit?: number + 'sort-field'?: string + 'sort-order'?: string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + page: { + type: 'number', + describe: help.list.page, + }, + limit: { + type: 'number', + describe: help.list.limit, + }, + 'sort-field': { + type: 'string', + choices: ['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at'], + describe: help.list['sort-field'], + }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.list['sort-order'], + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.folders + .getPaginated(args['project-code'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + }) + .catch(handleValidationError(buildArgumentMap(['page', 'limit', 'sort-field', 'sort-order']))) + printJson(result) + }), +} + +interface FoldersBulkCreateArgs { + 'project-code': string + folders: string +} + +const bulkCreateCommand: CommandModule = { + command: 'bulk-create', + describe: help['bulk-create'].command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + folders: { + type: 'string', + demandOption: true, + describe: help['bulk-create'].folders, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help['bulk-create'].epilog), + handler: apiHandler(async (args, connectApi) => { + const folders = parseAndValidateJsonArg( + args.folders, + '--folders', + BulkCreateFoldersRequestSchema + ) + const api = connectApi() + const result = await api.folders + .bulkCreate(args['project-code'], folders) + .catch(handleValidationError(buildArgumentMap(['folders']))) + printJson(result) + }), +} + +export const foldersCommand: CommandModule = { + command: 'folders', + describe: 'Manage folders', + builder: (yargs: Argv) => + yargs.command(listCommand).command(bulkCreateCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/folders/help.ts b/src/commands/api/folders/help.ts new file mode 100644 index 0000000..879db18 --- /dev/null +++ b/src/commands/api/folders/help.ts @@ -0,0 +1,32 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List folders in a project.', + page: 'Page number for pagination (starts at 1).', + limit: 'Maximum number of items per page.', + 'sort-field': + 'Field to sort by (id, project_id, title, pos, parent_id, created_at, updated_at).', + 'sort-order': 'Sort direction.', + epilog: apiDocsEpilog('folders', 'list-project-folders'), + }, + + 'bulk-create': { + command: 'Create or update multiple folders at once.', + folders: `JSON array of folder objects. Each folder has a "path" (string array) and optional "comment" (supports HTML). +Accepts inline JSON or @filename. +Example: '[{"path": ["Parent", "Child"]}]'`, + epilog: apiDocsEpilog('folders', 'bulk-upsert-folders'), + }, + + examples: [ + { + usage: + '$0 api folders bulk-create --project-code PRJ --folders \'[{"path": ["Suite", "Auth"]}]\'', + description: 'Create nested folders', + }, + ], +} as const diff --git a/src/commands/api/main.ts b/src/commands/api/main.ts new file mode 100644 index 0000000..6605337 --- /dev/null +++ b/src/commands/api/main.ts @@ -0,0 +1,50 @@ +import { Argv, CommandModule } from 'yargs' +import { validateProjectCode } from './utils' +import { auditLogsCommand } from './audit-logs/command' +import { customFieldsCommand } from './custom-fields/command' +import { filesCommand } from './files/command' +import { foldersCommand } from './folders/command' +import { milestonesCommand } from './milestones/command' +import { projectsCommand } from './projects/command' +import { requirementsCommand } from './requirements/command' +import { resultsCommand } from './results/command' +import { runsCommand } from './runs/command' +import { settingsCommand } from './settings/command' +import { sharedPreconditionsCommand } from './shared-preconditions/command' +import { sharedStepsCommand } from './shared-steps/command' +import { tagsCommand } from './tags/command' +import { testCasesCommand } from './test-cases/command' +import { testPlansCommand } from './test-plans/command' +import { usersCommand } from './users/command' + +export const apiCommand: CommandModule = { + command: 'api', + describe: 'Access QA Sphere API directly', + builder: (yargs: Argv) => + yargs + .command(auditLogsCommand) + .command(customFieldsCommand) + .command(filesCommand) + .command(foldersCommand) + .command(milestonesCommand) + .command(projectsCommand) + .command(requirementsCommand) + .command(resultsCommand) + .command(runsCommand) + .command(settingsCommand) + .command(sharedPreconditionsCommand) + .command(sharedStepsCommand) + .command(tagsCommand) + .command(testCasesCommand) + .command(testPlansCommand) + .command(usersCommand) + .demandCommand(1, '') + .check((argv) => { + const projectCode = argv['project-code'] as string | undefined + if (projectCode) { + validateProjectCode([projectCode, '--project-code']) + } + return true + }), + handler: () => {}, +} diff --git a/src/commands/api/milestones/command.ts b/src/commands/api/milestones/command.ts new file mode 100644 index 0000000..53141d7 --- /dev/null +++ b/src/commands/api/milestones/command.ts @@ -0,0 +1,74 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, buildArgumentMap, handleValidationError, printJson } from '../utils' +import help from './help' + +interface MilestonesListArgs { + 'project-code': string + archived?: boolean +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + archived: { + type: 'boolean', + describe: help.list.archived, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.milestones + .list(args['project-code'], args) + .catch(handleValidationError(buildArgumentMap(['archived']))) + printJson(result) + }), +} + +interface MilestonesCreateArgs { + 'project-code': string + title: string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + title: { + type: 'string', + demandOption: true, + describe: help.create.title, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.milestones + .create(args['project-code'], args) + .catch(handleValidationError(buildArgumentMap(['title']))) + printJson(result) + }), +} + +export const milestonesCommand: CommandModule = { + command: 'milestones', + describe: 'Manage milestones', + builder: (yargs: Argv) => yargs.command(listCommand).command(createCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/milestones/help.ts b/src/commands/api/milestones/help.ts new file mode 100644 index 0000000..78863e0 --- /dev/null +++ b/src/commands/api/milestones/help.ts @@ -0,0 +1,25 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List milestones in a project.', + archived: 'Filter by archived status. If omitted, returns all milestones.', + epilog: apiDocsEpilog('milestone', 'list-project-milestones'), + }, + + create: { + command: 'Create a new milestone.', + title: 'Display title for the milestone (max 255 characters).', + epilog: apiDocsEpilog('milestone', 'create-milestone'), + }, + + examples: [ + { + usage: '$0 api milestones create --project-code PRJ --title "v1.0"', + description: 'Create a new milestone', + }, + ], +} as const diff --git a/src/commands/api/projects/command.ts b/src/commands/api/projects/command.ts new file mode 100644 index 0000000..6e7a124 --- /dev/null +++ b/src/commands/api/projects/command.ts @@ -0,0 +1,116 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseOptionalJsonField, + printJson, + validateProjectCode, +} from '../utils' +import { projectLinksSchema } from '../../../api/projects' +import help from './help' + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => yargs.epilog(help.list.epilog), + handler: apiHandler(async (_args, connectApi) => { + const api = connectApi() + const result = await api.projects.list() + printJson(result) + }), +} + +interface ProjectsGetArgs { + 'project-code': string +} + +const getCommand: CommandModule = { + command: 'get', + describe: help.get.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + }) + .epilog(help.get.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.projects.get(args['project-code']) + printJson(result) + }), +} + +interface ProjectsCreateArgs { + code: string + title: string + links?: string + 'overview-title'?: string + 'overview-description'?: string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + code: { + type: 'string', + demandOption: true, + describe: help.create.code, + }, + title: { + type: 'string', + demandOption: true, + describe: help.create.title, + }, + links: { + type: 'string', + describe: help.create.links, + }, + 'overview-title': { + type: 'string', + describe: help.create['overview-title'], + }, + 'overview-description': { + type: 'string', + describe: help.create['overview-description'], + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog) + .check((argv) => { + validateProjectCode([argv.code, '--code']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const links = parseOptionalJsonField(args.links, '--links', projectLinksSchema) + const api = connectApi() + const result = await api.projects + .create({ + ...args, + overviewTitle: args['overview-title'], + overviewDescription: args['overview-description'], + links, + }) + .catch( + handleValidationError( + buildArgumentMap(['code', 'title', 'overview-title', 'overview-description', 'links']) + ) + ) + printJson(result) + }), +} + +export const projectsCommand: CommandModule = { + command: 'projects', + describe: 'Manage projects', + builder: (yargs: Argv) => + yargs.command(listCommand).command(getCommand).command(createCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/projects/help.ts b/src/commands/api/projects/help.ts new file mode 100644 index 0000000..67af4bc --- /dev/null +++ b/src/commands/api/projects/help.ts @@ -0,0 +1,36 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code or ID identifying the QA Sphere project.`, + + list: { + command: 'List all projects.', + epilog: apiDocsEpilog('projects', 'list-projects'), + }, + + get: { + command: 'Get a project by code or ID.', + epilog: apiDocsEpilog('projects', 'get-project'), + }, + + create: { + command: 'Create a new project.', + code: `Short project code (2-5 alphanumeric characters, e.g., "PRJ"). +Used in URLs and test case references.`, + title: `Display title for the project (max 255 characters).`, + links: `JSON array of project links. Each link has "text" and "url" fields. +Accepts inline JSON or @filename. +Example: '[{"text": "Docs", "url": "https://example.com"}]'`, + 'overview-title': 'Title for the project overview page (max 255 characters).', + 'overview-description': 'Description for the project overview page (supports HTML).', + epilog: apiDocsEpilog('projects', 'create-project'), + }, + + examples: [ + { + usage: '$0 api projects create --code PRJ --title "My Project"', + description: 'Create a new project', + }, + ], +} as const diff --git a/src/commands/api/requirements/command.ts b/src/commands/api/requirements/command.ts new file mode 100644 index 0000000..aaf01f4 --- /dev/null +++ b/src/commands/api/requirements/command.ts @@ -0,0 +1,63 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + printJson, + type SortOrder, +} from '../utils' +import help from './help' + +interface RequirementsListArgs { + 'project-code': string + 'sort-field'?: string + 'sort-order'?: string + include?: string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'sort-field': { + type: 'string', + choices: ['created_at', 'text'], + describe: help.list['sort-field'], + }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.list['sort-order'], + }, + include: { + type: 'string', + describe: help.list.include, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.requirements + .list(args['project-code'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + }) + .catch(handleValidationError(buildArgumentMap(['sort-field', 'sort-order', 'include']))) + printJson(result) + }), +} + +export const requirementsCommand: CommandModule = { + command: 'requirements', + describe: 'Manage requirements', + builder: (yargs: Argv) => yargs.command(listCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/requirements/help.ts b/src/commands/api/requirements/help.ts new file mode 100644 index 0000000..14745ed --- /dev/null +++ b/src/commands/api/requirements/help.ts @@ -0,0 +1,14 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List requirements in a project.', + 'sort-field': 'Field to sort by (created_at or text).', + 'sort-order': 'Sort direction (asc or desc).', + include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + epilog: apiDocsEpilog('requirements', 'list-requirements'), + }, +} as const diff --git a/src/commands/api/results/command.ts b/src/commands/api/results/command.ts new file mode 100644 index 0000000..4d0d214 --- /dev/null +++ b/src/commands/api/results/command.ts @@ -0,0 +1,160 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseAndValidateJsonArg, + parseOptionalJsonField, + printJson, + validateIntId, + validateResourceId, +} from '../utils' +import { z } from 'zod' +import { + type CreateResultRequest, + CreateResultsRequestSchema, + resultLinksSchema, +} from '../../../api/results' +import help from './help' + +/** + * Accepts either `{ items: [...] }` or a bare array of result items. + * A bare array is automatically wrapped into the `{ items }` shape. + */ +const batchCreateResultsInputSchema = z + .unknown() + .transform((val) => (Array.isArray(val) ? { items: val } : val)) + .pipe(CreateResultsRequestSchema) + +interface ResultsCreateArgs { + 'project-code': string + 'run-id': number + 'tcase-id': string + status: string + comment?: string + 'time-taken'?: number + links?: string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + 'tcase-id': { + type: 'string', + demandOption: true, + describe: help.create['tcase-id'], + }, + status: { + type: 'string', + demandOption: true, + choices: [ + 'passed', + 'failed', + 'blocked', + 'skipped', + 'open', + 'custom1', + 'custom2', + 'custom3', + 'custom4', + ], + describe: help.create.status, + }, + comment: { + type: 'string', + describe: help.create.comment, + }, + 'time-taken': { + type: 'number', + describe: help.create['time-taken'], + }, + links: { + type: 'string', + describe: help.create.links, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + validateResourceId([argv['tcase-id'], '--tcase-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const links = parseOptionalJsonField(args.links, '--links', resultLinksSchema) + const api = connectApi() + const result = await api.results + .create(args['project-code'], args['run-id'], args['tcase-id'], { + ...args, + status: args.status as CreateResultRequest['status'], + timeTaken: args['time-taken'], + links, + }) + .catch(handleValidationError(buildArgumentMap(['status', 'comment', 'time-taken', 'links']))) + printJson(result) + }), +} + +interface ResultsBatchCreateArgs { + 'project-code': string + 'run-id': number + items: string +} + +const batchCreateCommand: CommandModule = { + command: 'batch-create', + describe: help['batch-create'].command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + items: { + type: 'string', + demandOption: true, + describe: help['batch-create'].items, + }, + }) + .epilog(help['batch-create'].epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const body = parseAndValidateJsonArg(args.items, '--items', batchCreateResultsInputSchema) + const api = connectApi() + const result = await api.results + .createBatch(args['project-code'], args['run-id'], body) + .catch(handleValidationError(buildArgumentMap(['items']))) + printJson(result) + }), +} + +export const resultsCommand: CommandModule = { + command: 'results', + describe: 'Manage test results', + builder: (yargs: Argv) => + yargs.command(createCommand).command(batchCreateCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/results/help.ts b/src/commands/api/results/help.ts new file mode 100644 index 0000000..04d1713 --- /dev/null +++ b/src/commands/api/results/help.ts @@ -0,0 +1,36 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + 'run-id': 'Test run ID.', + + create: { + command: 'Create a result for a test case in a run.', + 'tcase-id': 'Test case ID within the run.', + status: + 'Result status (passed, failed, blocked, skipped, open, custom1, custom2, custom3, custom4).', + comment: 'Result comment (supports HTML).', + 'time-taken': 'Time taken in milliseconds.', + links: `JSON array of result links. Each link has "text" and "url" fields. +Accepts inline JSON or @filename. +Example: '[{"text": "Log", "url": "https://ci.example.com/123"}]'`, + epilog: apiDocsEpilog('result', 'add-result'), + }, + + 'batch-create': { + command: 'Create results for multiple test cases in a run.', + items: `JSON array of result items for batch creation. +Accepts inline JSON or @filename. +Each item has: tcaseId (string), status (string), comment? (string, supports HTML), timeTaken? (number|null), links? (array). +Example: '[{"tcaseId": "abc", "status": "passed"}]'`, + epilog: apiDocsEpilog('result', 'add-multiple-results'), + }, + + examples: [ + { + usage: '$0 api results create --project-code PRJ --run-id 1 --tcase-id abc --status passed', + description: 'Create a passed result', + }, + ], +} as const diff --git a/src/commands/api/runs/command.ts b/src/commands/api/runs/command.ts new file mode 100644 index 0000000..1f40275 --- /dev/null +++ b/src/commands/api/runs/command.ts @@ -0,0 +1,388 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseAndValidateJsonArg, + printJson, + type SortOrder, + validateIntId, + validateResourceId, +} from '../utils' +import { QueryPlansSchema, type ListRunTCasesRequest } from '../../../api/runs' +import help from './help' + +interface RunsCreateArgs { + 'project-code': string + title: string + type: 'static' | 'static_struct' | 'live' + description?: string + 'milestone-id'?: number + 'configuration-id'?: string + 'assignment-id'?: number + 'query-plans': string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + title: { + type: 'string', + demandOption: true, + describe: help.create.title, + }, + type: { + type: 'string', + demandOption: true, + choices: ['static', 'static_struct', 'live'] as const, + describe: help.create.type, + }, + description: { + type: 'string', + describe: help.create.description, + }, + 'milestone-id': { + type: 'number', + describe: help.create['milestone-id'], + }, + 'configuration-id': { + type: 'string', + describe: help.create['configuration-id'], + }, + 'assignment-id': { + type: 'number', + describe: help.create['assignment-id'], + }, + 'query-plans': { + type: 'string', + demandOption: true, + describe: help.create['query-plans'], + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog), + handler: apiHandler(async (args, connectApi) => { + const queryPlans = parseAndValidateJsonArg( + args['query-plans'], + '--query-plans', + QueryPlansSchema + ) + const api = connectApi() + const result = await api.runs + .create(args['project-code'], { + ...args, + milestoneId: args['milestone-id'], + configurationId: args['configuration-id'], + assignmentId: args['assignment-id'], + queryPlans, + }) + .catch( + handleValidationError( + buildArgumentMap([ + 'title', + 'description', + 'type', + 'milestone-id', + 'configuration-id', + 'assignment-id', + 'query-plans', + ]) + ) + ) + printJson(result) + }), +} + +interface RunsListArgs { + 'project-code': string + closed?: boolean + 'milestone-ids'?: string + limit?: number +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + closed: { + type: 'boolean', + describe: help.list.closed, + }, + 'milestone-ids': { + type: 'string', + describe: help.list['milestone-ids'], + }, + limit: { + type: 'number', + describe: help.list.limit, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.runs + .list(args['project-code'], { + ...args, + milestoneIds: args['milestone-ids']?.split(',').map(Number), + }) + .catch(handleValidationError(buildArgumentMap(['closed', 'milestone-ids', 'limit']))) + printJson(result) + }), +} + +interface RunsCloneArgs { + 'project-code': string + 'run-id': number + title: string + description?: string + 'milestone-id'?: number + 'assignment-id'?: number +} + +const cloneCommand: CommandModule = { + command: 'clone', + describe: help.clone.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + title: { + type: 'string', + demandOption: true, + describe: help.clone.title, + }, + description: { + type: 'string', + describe: help.clone.description, + }, + 'milestone-id': { + type: 'number', + describe: help.clone['milestone-id'], + }, + 'assignment-id': { + type: 'number', + describe: help.clone['assignment-id'], + }, + }) + .epilog(help.clone.epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.runs + .clone(args['project-code'], { + ...args, + runId: args['run-id'], + milestoneId: args['milestone-id'], + assignmentId: args['assignment-id'], + }) + .catch( + handleValidationError( + buildArgumentMap(['run-id', 'title', 'description', 'milestone-id', 'assignment-id']) + ) + ) + printJson(result) + }), +} + +interface RunsCloseArgs { + 'project-code': string + 'run-id': number +} + +const closeCommand: CommandModule = { + command: 'close', + describe: help.close.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + }) + .epilog(help.close.epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.runs.close(args['project-code'], args['run-id']) + printJson(result) + }), +} + +// Nested tcases subgroup + +interface RunsTCasesListArgs { + 'project-code': string + 'run-id': number + search?: string + tags?: string + priorities?: string + include?: string + 'sort-field'?: string + 'sort-order'?: string +} + +const tcasesListCommand: CommandModule = { + command: 'list', + describe: help.tcases.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + search: { + type: 'string', + describe: help.tcases.list.search, + }, + tags: { + type: 'string', + describe: help.tcases.list.tags, + }, + priorities: { + type: 'string', + describe: help.tcases.list.priorities, + }, + include: { + type: 'string', + describe: help.tcases.list.include, + }, + 'sort-field': { + type: 'string', + describe: help.tcases.list['sort-field'], + }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.tcases.list['sort-order'], + }, + }) + .epilog(help.tcases.list.epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.runs + .listTCases(args['project-code'], args['run-id'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + tags: args.tags?.split(',').map(Number), + priorities: args.priorities?.split(',') as ListRunTCasesRequest['priorities'], + }) + .catch( + handleValidationError( + buildArgumentMap(['search', 'tags', 'priorities', 'include', 'sort-field', 'sort-order']) + ) + ) + printJson(result) + }), +} + +interface RunsTCasesGetArgs { + 'project-code': string + 'run-id': number + 'tcase-id': string +} + +const tcasesGetCommand: CommandModule = { + command: 'get', + describe: help.tcases.get.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'run-id': { + type: 'number', + demandOption: true, + describe: help['run-id'], + }, + 'tcase-id': { + type: 'string', + demandOption: true, + describe: help.tcases.get['tcase-id'], + }, + }) + .epilog(help.tcases.get.epilog) + .check((argv) => { + validateIntId([argv['run-id'], '--run-id']) + validateResourceId([argv['tcase-id'], '--tcase-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.runs.getTCase(args['project-code'], args['run-id'], args['tcase-id']) + printJson(result) + }), +} + +const tcasesCommand: CommandModule = { + command: 'tcases', + describe: help.tcases.command, + builder: (yargs: Argv) => + yargs.command(tcasesListCommand).command(tcasesGetCommand).demandCommand(1, ''), + handler: () => {}, +} + +export const runsCommand: CommandModule = { + command: 'runs', + describe: 'Manage test runs', + builder: (yargs: Argv) => + yargs + .command(createCommand) + .command(listCommand) + .command(cloneCommand) + .command(closeCommand) + .command(tcasesCommand) + .demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/runs/help.ts b/src/commands/api/runs/help.ts new file mode 100644 index 0000000..124c854 --- /dev/null +++ b/src/commands/api/runs/help.ts @@ -0,0 +1,103 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project. +This is the short uppercase code visible in the project URL (e.g., "PRJ" from /project/PRJ/...).`, + 'run-id': 'Test run ID.', + + create: { + command: 'Create a new test run.', + title: `Display title for the test run (1-255 characters). +Must be unique within the project. +If a run with this exact title already exists, the API will return an error.`, + type: `Run type. + +"static": flat list of test cases fixed after creation. +No propagation of versioned property updates. +Single query plan only. Supports tcaseIds and filter-based selection. + +"static_struct": test cases grouped by folder structure, fixed after creation +but updates to versioned properties (title, steps) are reflected for cases with open statuses. +Single query plan only. Supports tcaseIds and filter-based selection. + +"live": dynamic run that automatically adds/removes cases matching filter criteria. +Only filter-based selection allowed (folderIds, tagIds, priorities - no tcaseIds). +Supports multiple query plans combined via union. +Reflects versioned property updates for open-status cases.`, + description: 'Optional description for the test run (max 512 characters, supports HTML).', + 'milestone-id': `ID of the milestone to associate with this run. +Must be an active, non-archived milestone. +Use "qas api milestones list" to find available milestone IDs.`, + 'configuration-id': `ID of the configuration to associate with this run. +Use "qas api configurations list" to find available configuration IDs.`, + 'assignment-id': `ID of the user to assign to this run. +Must be an active user with a role above Viewer. +All test cases in the run will be assigned to this user.`, + 'query-plans': `JSON array of query plan objects that select which test cases to include. +Accepts inline JSON or @filename (reads JSON from file). + +Each plan can have: + tcaseIds (string[]) - specific test case IDs (not allowed for "live" runs) + folderIds (number[]) - include all cases in folders (includes subfolders) + tagIds (number[]) - filter by tags + priorities (string[], values: "low", "medium", "high") - filter by priority + +Within a plan, filters are combined with AND. +For "static" and "static_struct" runs, exactly one query plan is allowed. +For "live" runs, multiple plans are combined via OR (union). +Only standalone and filled test case types are included. + +Example: '[{"folderIds": [1, 2], "priorities": ["high"]}]'`, + epilog: apiDocsEpilog('run', 'create-new-run'), + }, + + list: { + command: 'List test runs in a project.', + epilog: apiDocsEpilog('run', 'list-project-runs'), + closed: 'Filter by closed status. If true, returns only closed runs.', + 'milestone-ids': `Comma-separated milestone IDs to filter by (e.g., "1,2,3").`, + limit: 'Maximum number of items to return.', + }, + + clone: { + command: 'Clone an existing test run.', + title: `Display title for the cloned run (1-255 characters).`, + description: 'Optional description for the cloned run (supports HTML).', + 'milestone-id': 'Milestone ID for the cloned run.', + 'assignment-id': 'Assignment ID for the cloned run.', + epilog: apiDocsEpilog('run', 'clone-existing-run'), + }, + + close: { + command: 'Close a test run.', + epilog: apiDocsEpilog('run', 'close-run'), + }, + + tcases: { + command: 'Manage test cases within a run.', + list: { + command: 'List test cases in a run.', + epilog: apiDocsEpilog('run', 'list-run-test-cases'), + search: 'Search text to filter test cases.', + tags: 'Comma-separated tag IDs to filter by.', + priorities: 'Comma-separated priorities to filter by (e.g., "low,high").', + include: 'Include additional fields. Use "folder" to include folder details.', + 'sort-field': 'Field to sort by.', + 'sort-order': 'Sort direction (asc or desc).', + }, + get: { + command: 'Get a specific test case in a run.', + 'tcase-id': 'Test case ID or legacy ID.', + epilog: apiDocsEpilog('run', 'get-run-test-case'), + }, + }, + + examples: [ + { + usage: + '$0 api runs create --project-code PRJ --title "Sprint 1" --type static --query-plans \'[{"tcaseIds": ["abc123"]}]\'', + description: 'Create a static run with specific test cases', + }, + ], +} as const diff --git a/src/commands/api/settings/command.ts b/src/commands/api/settings/command.ts new file mode 100644 index 0000000..fd21c6e --- /dev/null +++ b/src/commands/api/settings/command.ts @@ -0,0 +1,69 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseAndValidateJsonArg, + printJson, +} from '../utils' +import { z } from 'zod' +import help from './help' +import { CUSTOM_STATUS_IDS, STATUS_COLORS } from '../../../api/settings' + +const statusItemSchema = z.object({ + id: z.enum(CUSTOM_STATUS_IDS), + name: z.string().min(1, 'name must not be empty'), + color: z.enum(STATUS_COLORS, { message: `color must be one of ${STATUS_COLORS.join(', ')}` }), + isActive: z.boolean(), +}) + +const updateStatusesInputSchema = z + .array(statusItemSchema) + .min(1, 'Must contain at least one status') + +const listStatusesCommand: CommandModule = { + command: 'list-statuses', + describe: help['list-statuses'].command, + builder: (yargs: Argv) => yargs.epilog(help['list-statuses'].epilog), + handler: apiHandler(async (_args, connectApi) => { + const api = connectApi() + const result = await api.settings.list() + printJson(result) + }), +} + +interface SettingsUpdateStatusesArgs { + statuses: string +} + +const updateStatusesCommand: CommandModule = { + command: 'update-statuses', + describe: help['update-statuses'].command, + builder: (yargs: Argv) => + yargs + .options({ + statuses: { + type: 'string', + demandOption: true, + describe: help['update-statuses'].statuses, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help['update-statuses'].epilog), + handler: apiHandler(async (args, connectApi) => { + const statuses = parseAndValidateJsonArg(args.statuses, '--statuses', updateStatusesInputSchema) + const api = connectApi() + const result = await api.settings + .update({ statuses }) + .catch(handleValidationError(buildArgumentMap(['statuses']))) + printJson(result) + }), +} + +export const settingsCommand: CommandModule = { + command: 'settings', + describe: 'Manage settings', + builder: (yargs: Argv) => + yargs.command(listStatusesCommand).command(updateStatusesCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/settings/help.ts b/src/commands/api/settings/help.ts new file mode 100644 index 0000000..0e84bc5 --- /dev/null +++ b/src/commands/api/settings/help.ts @@ -0,0 +1,27 @@ +import { STATUS_COLORS } from '../../../api/settings' +import { apiDocsEpilog } from '../utils' + +export default { + 'list-statuses': { + command: 'List all result statuses (including custom statuses).', + epilog: apiDocsEpilog('settings', 'get-statuses'), + }, + + 'update-statuses': { + command: 'Update custom result statuses.', + statuses: `JSON array of custom status objects. +Accepts inline JSON or @filename. +Each status has: id (custom1-4), name (string), color (named color), isActive (boolean). +Valid colors: ${STATUS_COLORS.join(', ')}. +Example: '[{"id": "custom1", "name": "Retest", "color": "orange", "isActive": true}]'`, + epilog: apiDocsEpilog('settings', 'update-custom-statuses'), + }, + + examples: [ + { + usage: + '$0 api settings update-statuses --statuses \'[{"id": "custom1", "name": "Retest", "color": "orange", "isActive": true}]\'', + description: 'Activate a custom status', + }, + ], +} as const diff --git a/src/commands/api/shared-preconditions/command.ts b/src/commands/api/shared-preconditions/command.ts new file mode 100644 index 0000000..8ae3559 --- /dev/null +++ b/src/commands/api/shared-preconditions/command.ts @@ -0,0 +1,98 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + printJson, + validateIntId, + type SortOrder, +} from '../utils' +import help from './help' + +interface SharedPreconditionsListArgs { + 'project-code': string + 'sort-field'?: string + 'sort-order'?: string + include?: string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'sort-field': { + type: 'string', + choices: ['created_at', 'title'], + describe: help.list['sort-field'], + }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.list['sort-order'], + }, + include: { + type: 'string', + describe: help.list.include, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.sharedPreconditions + .list(args['project-code'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + }) + .catch(handleValidationError(buildArgumentMap(['sort-field', 'sort-order', 'include']))) + printJson(result) + }), +} + +interface SharedPreconditionsGetArgs { + 'project-code': string + id: number +} + +const getCommand: CommandModule = { + command: 'get', + describe: help.get.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + id: { + type: 'number', + demandOption: true, + describe: help.get.id, + }, + }) + .epilog(help.get.epilog) + .check((argv) => { + validateIntId([argv.id, '--id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.sharedPreconditions.get(args['project-code'], args.id) + printJson(result) + }), +} + +export const sharedPreconditionsCommand: CommandModule = { + command: 'shared-preconditions', + describe: 'Manage shared preconditions', + builder: (yargs: Argv) => yargs.command(listCommand).command(getCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/shared-preconditions/help.ts b/src/commands/api/shared-preconditions/help.ts new file mode 100644 index 0000000..773cd95 --- /dev/null +++ b/src/commands/api/shared-preconditions/help.ts @@ -0,0 +1,20 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List shared preconditions in a project.', + 'sort-field': 'Field to sort by (created_at or title).', + 'sort-order': 'Sort direction (asc or desc).', + include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + epilog: apiDocsEpilog('shared_preconditions', 'list-shared-preconditions'), + }, + + get: { + command: 'Get a shared precondition by ID. The response "text" field contains HTML.', + id: 'Shared precondition ID.', + epilog: apiDocsEpilog('shared_preconditions', 'get-shared-precondition'), + }, +} as const diff --git a/src/commands/api/shared-steps/command.ts b/src/commands/api/shared-steps/command.ts new file mode 100644 index 0000000..cdd18b4 --- /dev/null +++ b/src/commands/api/shared-steps/command.ts @@ -0,0 +1,98 @@ +import { Argv, CommandModule } from 'yargs' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + printJson, + validateIntId, + type SortOrder, +} from '../utils' +import help from './help' + +interface SharedStepsListArgs { + 'project-code': string + 'sort-field'?: string + 'sort-order'?: string + include?: string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'sort-field': { + type: 'string', + choices: ['created_at', 'title'], + describe: help.list['sort-field'], + }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.list['sort-order'], + }, + include: { + type: 'string', + describe: help.list.include, + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.sharedSteps + .list(args['project-code'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + }) + .catch(handleValidationError(buildArgumentMap(['sort-field', 'sort-order', 'include']))) + printJson(result) + }), +} + +interface SharedStepsGetArgs { + 'project-code': string + id: number +} + +const getCommand: CommandModule = { + command: 'get', + describe: help.get.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + id: { + type: 'number', + demandOption: true, + describe: help.get.id, + }, + }) + .epilog(help.get.epilog) + .check((argv) => { + validateIntId([argv.id, '--id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.sharedSteps.get(args['project-code'], args.id) + printJson(result) + }), +} + +export const sharedStepsCommand: CommandModule = { + command: 'shared-steps', + describe: 'Manage shared steps', + builder: (yargs: Argv) => yargs.command(listCommand).command(getCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/shared-steps/help.ts b/src/commands/api/shared-steps/help.ts new file mode 100644 index 0000000..af20ae6 --- /dev/null +++ b/src/commands/api/shared-steps/help.ts @@ -0,0 +1,21 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List shared steps in a project.', + 'sort-field': 'Field to sort by (created_at or title).', + 'sort-order': 'Sort direction (asc or desc).', + include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + epilog: apiDocsEpilog('shared_steps', 'list-shared-steps'), + }, + + get: { + command: + 'Get a shared step by ID. The response "description", "expected", and sub-step fields contain HTML.', + id: 'Shared step ID.', + epilog: apiDocsEpilog('shared_steps', 'get-shared-step'), + }, +} as const diff --git a/src/commands/api/tags/command.ts b/src/commands/api/tags/command.ts new file mode 100644 index 0000000..35ae727 --- /dev/null +++ b/src/commands/api/tags/command.ts @@ -0,0 +1,34 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, printJson } from '../utils' +import help from './help' + +interface TagsListArgs { + 'project-code': string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.tags.list(args['project-code']) + printJson(result) + }), +} + +export const tagsCommand: CommandModule = { + command: 'tags', + describe: 'Manage tags', + builder: (yargs: Argv) => yargs.command(listCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/tags/help.ts b/src/commands/api/tags/help.ts new file mode 100644 index 0000000..73aa3b7 --- /dev/null +++ b/src/commands/api/tags/help.ts @@ -0,0 +1,11 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + list: { + command: 'List all tags in a project.', + epilog: apiDocsEpilog('tag', 'list-project-tags'), + }, +} as const diff --git a/src/commands/api/test-cases/command.ts b/src/commands/api/test-cases/command.ts new file mode 100644 index 0000000..0537184 --- /dev/null +++ b/src/commands/api/test-cases/command.ts @@ -0,0 +1,466 @@ +import { Argv, CommandModule } from 'yargs' +import { z } from 'zod' +import { + apiHandler, + buildArgumentMap, + handleValidationError, + parseAndValidateJsonArg, + parseOptionalJsonField, + printJson, + type SortOrder, + validateResourceId, +} from '../utils' +import { + type CreateTCaseRequest, + type CountTCasesRequest, + type ListTCasesRequest, + customFieldsSchema, + parameterValueSchema, + parameterValueWithIdSchema, + StepsArraySchema, + type UpdateTCaseRequest, +} from '../../../api/tcases' +import help from './help' + +function mergeBodyWithOverrides( + bodyArg: string | undefined, + overrides: Record +): Record { + const bodyFromJson = bodyArg + ? parseAndValidateJsonArg(bodyArg, '--body', z.record(z.unknown())) + : {} + return { + ...bodyFromJson, + ...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined)), + } +} + +interface TCasesListArgs { + 'project-code': string + page?: number + limit?: number + folders?: string + tags?: string + priorities?: string + search?: string + types?: string + draft?: boolean + 'sort-field'?: string + 'sort-order'?: string + include?: string +} + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + page: { type: 'number', describe: help.list.page }, + limit: { type: 'number', describe: help.list.limit }, + folders: { type: 'string', describe: help.list.folders }, + tags: { type: 'string', describe: help.list.tags }, + priorities: { type: 'string', describe: help.list.priorities }, + search: { type: 'string', describe: help.list.search }, + types: { type: 'string', describe: help.list.types }, + draft: { type: 'boolean', describe: help.draft }, + 'sort-field': { type: 'string', describe: help.list['sort-field'] }, + 'sort-order': { + type: 'string', + choices: ['asc', 'desc'], + describe: help.list['sort-order'], + }, + include: { type: 'string', describe: help.list.include }, + }) + .epilog(help.list.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.testCases + .list(args['project-code'], { + ...args, + sortField: args['sort-field'], + sortOrder: args['sort-order'] as SortOrder, + folders: args.folders?.split(',').map(Number), + tags: args.tags?.split(',').map(Number), + priorities: args.priorities?.split(',') as ListTCasesRequest['priorities'], + types: args.types?.split(',') as ListTCasesRequest['types'], + include: args.include?.split(','), + }) + .catch( + handleValidationError( + buildArgumentMap([ + 'page', + 'limit', + 'search', + 'draft', + 'include', + 'sort-field', + 'sort-order', + 'folders', + 'tags', + 'priorities', + 'types', + ]) + ) + ) + printJson(result) + }), +} + +interface TCasesGetArgs { + 'project-code': string + 'tcase-id': string +} + +const getCommand: CommandModule = { + command: 'get', + describe: help.get.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'tcase-id': { + type: 'string', + demandOption: true, + describe: help['tcase-id'], + }, + }) + .epilog(help.get.epilog) + .check((argv) => { + validateResourceId([argv['tcase-id'], '--tcase-id']) + return true + }), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.testCases.get(args['project-code'], args['tcase-id']) + printJson(result) + }), +} + +interface TCasesCountArgs { + 'project-code': string + folders?: string + recursive?: boolean + tags?: string + priorities?: string + draft?: boolean +} + +const countCommand: CommandModule = { + command: 'count', + describe: help.count.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + folders: { type: 'string', describe: help.count.folders }, + recursive: { type: 'boolean', describe: help.count.recursive }, + tags: { type: 'string', describe: help.count.tags }, + priorities: { type: 'string', describe: help.count.priorities }, + draft: { type: 'boolean', describe: help.draft }, + }) + .epilog(help.count.epilog), + handler: apiHandler(async (args, connectApi) => { + const api = connectApi() + const result = await api.testCases + .count(args['project-code'], { + ...args, + folders: args.folders?.split(',').map(Number), + tags: args.tags?.split(',').map(Number), + priorities: args.priorities?.split(',') as CountTCasesRequest['priorities'], + }) + .catch( + handleValidationError( + buildArgumentMap(['folders', 'tags', 'priorities', 'recursive', 'draft']) + ) + ) + printJson(result) + }), +} + +interface TCasesCreateArgs { + 'project-code': string + body?: string + title?: string + type?: string + 'folder-id'?: number + priority?: string + 'precondition-text'?: string + 'precondition-id'?: number + tags?: string + 'is-draft'?: boolean + steps?: string + 'custom-fields'?: string + 'parameter-values'?: string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + body: { + type: 'string', + describe: help.create.body, + }, + title: { + type: 'string', + describe: help.title, + }, + type: { + type: 'string', + choices: ['standalone', 'template'], + describe: help.create.type, + }, + 'folder-id': { + type: 'number', + describe: help.create['folder-id'], + }, + priority: { + type: 'string', + choices: ['low', 'medium', 'high'], + describe: help.priority, + }, + 'precondition-text': { + type: 'string', + describe: help['precondition-text'], + }, + 'precondition-id': { + type: 'number', + describe: help['precondition-id'], + }, + tags: { + type: 'string', + describe: help.tags, + }, + 'is-draft': { + type: 'boolean', + describe: help['is-draft'], + }, + steps: { + type: 'string', + describe: help.steps, + }, + 'custom-fields': { + type: 'string', + describe: help['custom-fields'], + }, + 'parameter-values': { + type: 'string', + describe: help['parameter-values'], + }, + }) + .check((argv) => { + if (argv['precondition-text'] && argv['precondition-id']) { + throw new Error('--precondition-text and --precondition-id are mutually exclusive') + } + return argv.body || argv.title ? true : 'Either --body or --title is required' + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog), + handler: apiHandler(async (args, connectApi) => { + const precondition = args['precondition-text'] + ? { text: args['precondition-text'] } + : args['precondition-id'] + ? { sharedPreconditionId: args['precondition-id'] } + : undefined + const body = mergeBodyWithOverrides(args.body, { + title: args.title, + type: args.type, + folderId: args['folder-id'], + priority: args.priority, + precondition, + isDraft: args['is-draft'], + tags: args.tags?.split(','), + steps: parseOptionalJsonField(args.steps, '--steps', StepsArraySchema), + customFields: parseOptionalJsonField( + args['custom-fields'], + '--custom-fields', + customFieldsSchema + ), + parameterValues: parseOptionalJsonField( + args['parameter-values'], + '--parameter-values', + z.array(parameterValueSchema) + ), + }) + const api = connectApi() + const result = await api.testCases + .create(args['project-code'], body as CreateTCaseRequest) + .catch( + handleValidationError( + buildArgumentMap([ + 'title', + 'type', + 'folder-id', + 'priority', + 'is-draft', + 'tags', + 'steps', + 'custom-fields', + 'parameter-values', + ]) + ) + ) + printJson(result) + }), +} + +interface TCasesUpdateArgs { + 'project-code': string + 'tcase-id': string + body?: string + title?: string + priority?: string + 'precondition-text'?: string + 'precondition-id'?: number + tags?: string + 'is-draft'?: boolean + steps?: string + 'custom-fields'?: string + 'parameter-values'?: string +} + +const updateCommand: CommandModule = { + command: 'update', + describe: help.update.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + 'tcase-id': { + type: 'string', + demandOption: true, + describe: help['tcase-id'], + }, + body: { + type: 'string', + describe: help.update.body, + }, + title: { + type: 'string', + describe: help.title, + }, + priority: { + type: 'string', + choices: ['low', 'medium', 'high'], + describe: help.priority, + }, + 'precondition-text': { + type: 'string', + describe: help['precondition-text'], + }, + 'precondition-id': { + type: 'number', + describe: help['precondition-id'], + }, + tags: { + type: 'string', + describe: help.tags, + }, + 'is-draft': { + type: 'boolean', + describe: help['is-draft'], + }, + steps: { + type: 'string', + describe: help.steps, + }, + 'custom-fields': { + type: 'string', + describe: help['custom-fields'], + }, + 'parameter-values': { + type: 'string', + describe: help['parameter-values'], + }, + }) + .check((argv) => { + if (argv['precondition-text'] && argv['precondition-id']) { + throw new Error('--precondition-text and --precondition-id are mutually exclusive') + } + validateResourceId([argv['tcase-id'], '--tcase-id']) + return true + }) + .epilog(help.update.epilog), + handler: apiHandler(async (args, connectApi) => { + const precondition = args['precondition-text'] + ? { text: args['precondition-text'] } + : args['precondition-id'] + ? { sharedPreconditionId: args['precondition-id'] } + : undefined + const body = mergeBodyWithOverrides(args.body, { + title: args.title, + priority: args.priority, + precondition, + isDraft: args['is-draft'], + tags: args.tags?.split(','), + steps: parseOptionalJsonField(args.steps, '--steps', StepsArraySchema), + customFields: parseOptionalJsonField( + args['custom-fields'], + '--custom-fields', + customFieldsSchema + ), + parameterValues: parseOptionalJsonField( + args['parameter-values'], + '--parameter-values', + z.array(parameterValueWithIdSchema) + ), + }) + const updateFields = [ + 'title', + 'priority', + 'is-draft', + 'tags', + 'steps', + 'custom-fields', + 'parameter-values', + ] + if (Object.keys(body).length === 0) { + const fieldList = updateFields.map((f) => `--${f}`).join(', ') + throw new Error(`At least one field to update is required (--body, ${fieldList})`) + } + const api = connectApi() + const result = await api.testCases + .update(args['project-code'], args['tcase-id'], body as UpdateTCaseRequest) + .catch(handleValidationError(buildArgumentMap(updateFields))) + printJson(result) + }), +} + +export const testCasesCommand: CommandModule = { + command: 'test-cases', + describe: 'Manage test cases', + builder: (yargs: Argv) => + yargs + .command(listCommand) + .command(getCommand) + .command(countCommand) + .command(createCommand) + .command(updateCommand) + .demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/test-cases/help.ts b/src/commands/api/test-cases/help.ts new file mode 100644 index 0000000..87f9686 --- /dev/null +++ b/src/commands/api/test-cases/help.ts @@ -0,0 +1,105 @@ +import { apiDocsEpilog } from '../utils' + +const bodyFields = ` +Fields: + title (string) - Test case title (1-511 characters). + type ("standalone" | "template") - Test case type. + folderId (number) - ID of the folder to place the test case in. + priority ("low" | "medium" | "high") - Priority level. + tags (string[]) - Array of tag names. + isDraft (boolean) - Whether the test case is a draft. + steps (object[]) - Array of step objects, each with: + description (string, supports HTML), expected (string, supports HTML), sharedStepId (number). + If sharedStepId is provided, description and expected are ignored. + precondition (object) - Either { text: string (supports HTML) } or { sharedPreconditionId: number }. + pos (number) - Position index within the folder (non-negative integer). + requirements (object[]) - Array of { text: string, url: string (max 255 chars) }. + links (object[]) - Array of { text: string, url: string }. + customFields (object) - Map of custom field key to { isDefault: boolean, value?: string }. + parameterValues (object[]) - Array of { values: { [paramName]: string } }. For updates, include tcaseId to modify existing filled cases. + filledTCaseTitleSuffixParams (string[]) - Parameter names used for filled test case title suffixes.` + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + 'tcase-id': 'Test case ID.', + draft: 'Filter by draft status (true or false).', + title: 'Test case title (1-511 characters).', + priority: 'Priority level (low, medium, or high).', + 'precondition-text': + 'Precondition text (supports HTML). Mutually exclusive with --precondition-id.', + 'precondition-id': 'Shared precondition ID. Mutually exclusive with --precondition-text.', + tags: 'Comma-separated tag names (e.g., "smoke,regression").', + 'is-draft': 'Whether the test case is a draft.', + steps: `JSON array of step objects. Accepts inline JSON or @filename. +Each step has: description (string, supports HTML), expected (string, supports HTML), sharedStepId (number). +If sharedStepId is provided, description and expected are ignored. +Example: '[{"description": "Click login", "expected": "Page loads"}]'`, + 'custom-fields': `JSON object mapping custom field keys to values. Accepts inline JSON or @filename. +Each value has: isDefault (boolean), value (string, omit if isDefault is true). +Use "qasphere api custom-fields list" to see available custom fields. +Example: '{"field1": {"isDefault": false, "value": "some value"}}'`, + 'parameter-values': `JSON array of parameter value sets. Accepts inline JSON or @filename. +Each entry has: values (object mapping parameter names to values). For updates, include tcaseId to modify existing filled cases. +Example: '[{"values": {"browser": "Chrome", "os": "Windows"}}]'`, + + // Command-specific groups + list: { + command: 'List test cases in a project.', + epilog: apiDocsEpilog('tcases', 'list-project-test-cases'), + page: 'Page number for pagination (starts at 1).', + limit: 'Maximum number of items per page.', + folders: 'Comma-separated folder IDs to filter by (e.g., "1,2,3").', + tags: 'Comma-separated tag IDs to filter by (e.g., "1,2,3").', + priorities: 'Comma-separated priorities to filter by (e.g., "low,high").', + search: 'Search text to filter test cases.', + types: 'Comma-separated test case types to filter by (e.g., "standalone,template").', + 'sort-field': 'Field to sort by.', + 'sort-order': 'Sort direction (asc or desc).', + include: + 'Comma-separated additional fields to include.\nValid values: steps, tags, requirements, customFields, parameterValues, folder, path, project.', + }, + + get: { + command: 'Get a test case by ID.', + epilog: apiDocsEpilog('tcases', 'get-test-case'), + }, + + count: { + command: 'Count test cases matching filters.', + epilog: apiDocsEpilog('tcases', 'get-test-case-count'), + folders: 'Comma-separated folder IDs to filter by (e.g., "1,2,3").', + tags: 'Comma-separated tag IDs to filter by (e.g., "1,2,3").', + priorities: 'Comma-separated priorities to filter by (e.g., "low,high").', + recursive: 'If true, include test cases in subfolders when filtering by folders.', + }, + + create: { + command: 'Create a new test case.', + type: 'Test case type (standalone or template).', + 'folder-id': 'ID of the folder to place the test case in.', + body: `JSON object for the full request body. +Accepts inline JSON or @filename (reads JSON from file). +Individual field options (--title, --tags, etc.) override body fields when both are provided. +${bodyFields}`, + epilog: apiDocsEpilog('tcases', 'create-test-case'), + }, + + update: { + command: 'Update an existing test case.', + body: `JSON object for the request body. +Accepts inline JSON or @filename (reads JSON from file). +Individual field options (--title, --tags, etc.) override body fields when both are provided. +All fields are optional. Only provided fields will be updated. +${bodyFields}`, + epilog: apiDocsEpilog('tcases', 'update-test-case'), + }, + + examples: [ + { + usage: + '$0 api test-cases create --project-code PRJ --body \'{"title": "Login test", "type": "standalone", "folderId": 1, "priority": "high"}\'', + description: 'Create a test case', + }, + ], +} as const diff --git a/src/commands/api/test-plans/command.ts b/src/commands/api/test-plans/command.ts new file mode 100644 index 0000000..ab01846 --- /dev/null +++ b/src/commands/api/test-plans/command.ts @@ -0,0 +1,45 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, handleValidationError, parseAndValidateJsonArg, printJson } from '../utils' +import { CreateTestPlanRequestSchema as createTestPlanBodySchema } from '../../../api/test-plans' +import help from './help' + +interface TestPlansCreateArgs { + 'project-code': string + body: string +} + +const createCommand: CommandModule = { + command: 'create', + describe: help.create.command, + builder: (yargs: Argv) => + yargs + .options({ + 'project-code': { + type: 'string', + demandOption: true, + describe: help['project-code'], + }, + body: { + type: 'string', + demandOption: true, + describe: help.create.body, + }, + }) + .example(help.examples[0].usage, help.examples[0].description) + .epilog(help.create.epilog), + handler: apiHandler(async (args, connectApi) => { + const body = parseAndValidateJsonArg(args.body, '--body', createTestPlanBodySchema) + const api = connectApi() + const result = await api.testPlans + .create(args['project-code'], body) + .catch(handleValidationError()) + printJson(result) + }), +} + +export const testPlansCommand: CommandModule = { + command: 'test-plans', + describe: 'Manage test plans', + builder: (yargs: Argv) => yargs.command(createCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/test-plans/help.ts b/src/commands/api/test-plans/help.ts new file mode 100644 index 0000000..49e9a42 --- /dev/null +++ b/src/commands/api/test-plans/help.ts @@ -0,0 +1,22 @@ +import { apiDocsEpilog } from '../utils' + +export default { + // Reusable fields shared across commands + 'project-code': `Project code identifying the QA Sphere project.`, + + create: { + command: 'Create a new test plan.', + body: `JSON object for the test plan body. +Accepts inline JSON or @filename. +Must include "title" and "runs" array. Each run needs "title", "type", and "queryPlans". +Example: '{"title": "Plan", "runs": [{"title": "Run 1", "type": "static", "queryPlans": [{"tcaseIds": ["abc"]}]}]}'`, + epilog: apiDocsEpilog('plan', 'create-new-test-plan'), + }, + + examples: [ + { + usage: '$0 api test-plans create --project-code PRJ --body @plan.json', + description: 'Create a test plan from a JSON file', + }, + ], +} as const diff --git a/src/commands/api/users/command.ts b/src/commands/api/users/command.ts new file mode 100644 index 0000000..95f7f7b --- /dev/null +++ b/src/commands/api/users/command.ts @@ -0,0 +1,21 @@ +import { Argv, CommandModule } from 'yargs' +import { apiHandler, printJson } from '../utils' +import help from './help' + +const listCommand: CommandModule = { + command: 'list', + describe: help.list.command, + builder: (yargs: Argv) => yargs.epilog(help.list.epilog), + handler: apiHandler(async (_args, connectApi) => { + const api = connectApi() + const result = await api.users.list() + printJson(result) + }), +} + +export const usersCommand: CommandModule = { + command: 'users', + describe: 'Manage users', + builder: (yargs: Argv) => yargs.command(listCommand).demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/api/users/help.ts b/src/commands/api/users/help.ts new file mode 100644 index 0000000..85197f0 --- /dev/null +++ b/src/commands/api/users/help.ts @@ -0,0 +1,8 @@ +import { apiDocsEpilog } from '../utils' + +export default { + list: { + command: 'List all users in the organization.', + epilog: apiDocsEpilog('users', 'list-users'), + }, +} as const diff --git a/src/commands/api/utils.ts b/src/commands/api/utils.ts new file mode 100644 index 0000000..9a5c282 --- /dev/null +++ b/src/commands/api/utils.ts @@ -0,0 +1,239 @@ +import { existsSync, readFileSync } from 'node:fs' +import chalk from 'chalk' +import { z, ZodError, ZodType } from 'zod' +import { loadEnvs } from '../../utils/env' +import { createApi, Api } from '../../api/index' +import { + RequestValidationError, + sortFieldParam, + sortOrderParam, + pageParam, + limitParam, + type SortOrder, +} from '../../api/schemas' + +export { sortFieldParam, sortOrderParam, pageParam, limitParam, type SortOrder } + +const RESOURCE_ID_REGEX = /^[a-zA-Z0-9_-]+$/ +const RESOURCE_ID_MESSAGE = 'must contain only alphanumeric characters, dashes, and underscores' + +export const resourceIdSchema = z.string().regex(RESOURCE_ID_REGEX, RESOURCE_ID_MESSAGE) + +export function validateResourceId(...params: [string, string][]): void { + const errors = params + .filter(([value]) => !RESOURCE_ID_REGEX.test(value)) + .map(([, name]) => `${name} ${RESOURCE_ID_MESSAGE}`) + if (errors.length > 0) { + throw new Error(errors.join('\n')) + } +} + +const PROJECT_CODE_REGEX = /^[a-zA-Z0-9]+$/ + +export function validateProjectCode(...params: [string, string][]): void { + const errors = params + .filter(([value]) => !PROJECT_CODE_REGEX.test(value)) + .map(([, name]) => `${name} must contain only latin letters and digits`) + if (errors.length > 0) { + throw new Error(errors.join('\n')) + } +} + +export function validateIntId(...params: [number, string][]): void { + const errors = params + .filter(([value]) => !Number.isInteger(value) || value <= 0) + .map(([, name]) => `${name} must be a positive integer`) + if (errors.length > 0) { + throw new Error(errors.join('\n')) + } +} + +export function printJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)) +} + +export const apiDocsEpilog = (resource: string, hash?: string) => + `API documentation: https://docs.qasphere.com/api/${resource}${hash ? `#${hash}` : ''}` + +/** + * Parses a CLI argument that accepts either inline JSON or a @filename reference. + * Provides detailed error messages for AI agents and human users. + */ +function parseJsonArg(value: string, optionName: string): unknown { + if (value.startsWith('@')) { + const filePath = value.slice(1) + if (!filePath) { + throw new Error(`${optionName} "@" must be followed by a file path (e.g., @plans.json)`) + } + if (!existsSync(filePath)) { + throw new Error(`File not found for ${optionName}: ${filePath}`) + } + const content = readFileSync(filePath, 'utf-8') + try { + return JSON.parse(content) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e) + throw new Error( + `Failed to parse JSON from file ${filePath} for ${optionName}: ${errorMessage}` + ) + } + } + + return mustParseJson(value, optionName) +} + +function mustParseJson(value: string, optionName: string) { + try { + return JSON.parse(value) + } catch (e) { + throw new Error( + `Failed to parse ${optionName} as JSON: ${e instanceof Error ? e.message : String(e)}\n` + + ` Provide valid inline JSON or use @filename to read from a file.\n` + + ` Inline example: ${optionName} '[{"tcaseIds": ["abc"]}]'\n` + + ` File example: ${optionName} @plans.json` + ) + } +} + +/** + * Wraps an API command handler with common setup: + * 1. Catches and formats errors with actionable messages + * 2. Provides a `connectApi()` function that lazily loads env vars and creates the API client + * (call this after validation so arg errors are reported before missing-env-var errors) + */ +export function apiHandler( + fn: (args: T, connectApi: () => Api) => Promise +): (args: T) => Promise { + return async (args) => { + try { + const connectApi = () => { + loadEnvs() + return createApi(process.env.QAS_URL!, process.env.QAS_TOKEN!) + } + await fn(args, connectApi) + } catch (e) { + formatApiError(e) + process.exit(1) + } + } +} + +/** + * Parses a JSON CLI argument and validates it against a Zod schema. + * Produces detailed error messages showing exactly which fields failed + * and what was expected, suitable for AI agents and human users. + */ +export function parseAndValidateJsonArg( + value: string, + optionName: string, + schema: ZodType +): T { + const parsed = parseJsonArg(value, optionName) + return validateWithSchema(parsed, optionName, schema) +} + +/** + * Validates an unknown value against a Zod schema, formatting errors + * with the CLI option name and path to each invalid field. + */ +export function parseOptionalJsonField( + value: string | undefined, + optionName: string, + schema: ZodType +): T | undefined { + return value ? parseAndValidateJsonArg(value, optionName, schema) : undefined +} + +export function validateWithSchema(value: unknown, optionName: string, schema: ZodType): T { + try { + return schema.parse(value) + } catch (e) { + if (e instanceof ZodError) { + throw new Error(formatZodError(e, optionName)) + } + throw e + } +} + +function formatZodError(error: ZodError, optionName: string): string { + const lines = [`Validation failed for ${optionName}:`] + for (const issue of error.issues) { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)' + lines.push(` - ${path}: ${issue.message}`) + } + return lines.join('\n') +} + +interface ArgumentValidationIssue { + argument: string + message: string +} + +export class ArgumentValidationError extends Error { + constructor(public readonly issues: ArgumentValidationIssue[]) { + super('Validation failed') + this.name = 'ArgumentValidationError' + } +} + +/** + * Builds an argument map from CLI argument names. + * Single-word args map directly (e.g., "search" → { search: "--search" }). + * Hyphenated args map to camelCase (e.g., "sort-field" → { sortField: "--sort-field" }). + */ +export function buildArgumentMap(args: string[]): Record { + const map: Record = {} + for (const arg of args) { + const camelCase = arg.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + map[camelCase] = `--${arg}` + } + return map +} + +export function handleValidationError(argumentMap?: Record): (e: unknown) => never { + return (e: unknown) => { + if (e instanceof RequestValidationError) { + const issues = e.zodError.issues.map((issue) => { + const fieldPath = issue.path.join('.') + const rootField = String(issue.path[0] ?? '') + const argument = argumentMap?.[rootField] ?? (fieldPath || '(root)') + return { argument, message: issue.message } + }) + throw new ArgumentValidationError(issues) + } + throw e + } +} + +function formatApiError(e: unknown): void { + const isVerbose = process.argv.some((arg) => arg === '--verbose') + + if (e instanceof ArgumentValidationError) { + console.error(`${chalk.red('Error:')} Invalid arguments:`) + for (const issue of e.issues) { + console.error(` ${issue.argument}: ${issue.message}`) + } + return + } + + if (e instanceof RequestValidationError) { + console.error(`${chalk.red('Error:')} Invalid request parameters:`) + for (const issue of e.zodError.issues) { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)' + console.error(` - ${path}: ${issue.message}`) + } + if (isVerbose) { + console.error(chalk.dim('\nRaw value:'), JSON.stringify(e.rawValue, null, 2)) + } + return + } + + if (e instanceof Error) { + console.error(`${chalk.red('Error:')} ${e.message}`) + if (isVerbose && e.stack) { + console.error(chalk.dim(e.stack)) + } + } else { + console.error(`${chalk.red('Error:')} ${String(e)}`) + } +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 81c3de0..a46a282 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,5 +1,6 @@ import yargs from 'yargs' import { ResultUploadCommandModule } from './resultUpload' +import { apiCommand } from './api/main' import { qasEnvs, qasEnvFile } from '../utils/env' import { CLI_VERSION } from '../utils/version' @@ -14,6 +15,7 @@ Required variables: ${qasEnvs.join(', ')} .command(new ResultUploadCommandModule('junit-upload')) .command(new ResultUploadCommandModule('playwright-json-upload')) .command(new ResultUploadCommandModule('allure-upload')) + .command(apiCommand) .demandCommand(1, '') .help('h') .alias('h', 'help') @@ -47,9 +49,11 @@ Required variables: ${qasEnvs.join(', ')} } else if (err) { console.error(String(err)) } else { - console.error('An unexpected error occurred.') + // No message and no error — likely a demandCommand('') failure (missing subcommand) + yi.showHelp() } process.exit(1) } }) + .completion() .parse() diff --git a/src/tests/api/audit-logs/list.spec.ts b/src/tests/api/audit-logs/list.spec.ts new file mode 100644 index 0000000..358dc50 --- /dev/null +++ b/src/tests/api/audit-logs/list.spec.ts @@ -0,0 +1,40 @@ +import { HttpResponse, http } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { test, baseURL, token, useMockServer, runCli } from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'audit-logs', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + after: 0, + count: 1, + events: [{ id: 'log1', action: 'create', timestamp: '2024-01-01T00:00:00Z', userId: 'u1' }], + } + + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/audit-logs`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastSearchParams = null + }) + + test('lists audit logs', async () => { + const result = await runCommand() + expect(result).toEqual(mockResponse) + }) + + test('passes pagination params', async () => { + await runCommand('--after', '100', '--count', '10') + expect(lastSearchParams).not.toBeNull() + expect(lastSearchParams!.get('after')).toBe('100') + expect(lastSearchParams!.get('count')).toBe('10') + }) +}) diff --git a/src/tests/api/custom-fields/list.spec.ts b/src/tests/api/custom-fields/list.spec.ts new file mode 100644 index 0000000..a64485f --- /dev/null +++ b/src/tests/api/custom-fields/list.spec.ts @@ -0,0 +1,44 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'custom-fields', 'list', ...args) + +describe('mocked', () => { + const mockFields = [{ id: 1, title: 'Browser', type: 'dropdown', isActive: true }] + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/custom-field`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ customFields: mockFields }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists custom fields in a project', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockFields) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) diff --git a/src/tests/api/files/upload.spec.ts b/src/tests/api/files/upload.spec.ts new file mode 100644 index 0000000..c739ea2 --- /dev/null +++ b/src/tests/api/files/upload.spec.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { HttpResponse, http } from 'msw' +import { afterAll, beforeAll, describe, expect } from 'vitest' +import { test, baseURL, token, useMockServer, runCli } from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'files', 'upload', ...args) + +describe('mocked', () => { + let tempDir: string + const mockFile = { id: 'file1', url: 'https://example.com/file1.png' } + + useMockServer( + http.post(`${baseURL}/api/public/v0/file/batch`, async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + return HttpResponse.json({ files: [mockFile] }) + }) + ) + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'qas-files-test-')) + }) + afterAll(() => { + rmSync(tempDir, { recursive: true }) + }) + + test('uploads a file', async () => { + const filePath = join(tempDir, 'screenshot.png') + writeFileSync(filePath, 'fake png content') + + const result = await runCommand('--file', filePath) + expect(result).toEqual(mockFile) + }) +}) diff --git a/src/tests/api/folders/bulk-create.spec.ts b/src/tests/api/folders/bulk-create.spec.ts new file mode 100644 index 0000000..d3dae0f --- /dev/null +++ b/src/tests/api/folders/bulk-create.spec.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, beforeAll, afterAll, describe, expect } from 'vitest' +import type { BulkCreateFoldersResponse } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'folders', 'bulk-create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + let tempDir: string + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tcase/folder/bulk`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ ids: [[1]] }) + } + ) + ) + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'qas-bulk-create-')) + }) + + afterAll(() => { + rmSync(tempDir, { recursive: true }) + }) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('bulk creates folders', async ({ project }) => { + const request = [{ path: ['Suite', 'Auth'] }] + const result = await runCommand( + '--project-code', + project.code, + '--folders', + JSON.stringify(request) + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ folders: request }) + expect(result).toEqual({ ids: [[1]] }) + }) + + test('bulk creates folders from @file', async ({ project }) => { + const filePath = join(tempDir, 'folders.json') + writeFileSync(filePath, JSON.stringify([{ path: ['FromFile', 'Nested'] }])) + await runCommand('--project-code', project.code, '--folders', `@${filePath}`) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ folders: [{ path: ['FromFile', 'Nested'] }] }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--folders', + JSON.stringify([{ path: ['Suite'] }]), + ]) +}) + +test('bulk creates folders on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--folders', + JSON.stringify([{ path: ['LiveTest', 'Auth'] }]) + ) + expect(result).toHaveProperty('ids') + expect(Array.isArray(result.ids)).toBe(true) +}) diff --git a/src/tests/api/folders/list.spec.ts b/src/tests/api/folders/list.spec.ts new file mode 100644 index 0000000..015c447 --- /dev/null +++ b/src/tests/api/folders/list.spec.ts @@ -0,0 +1,91 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Folder } from '../../../api/folders' +import type { PaginatedResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + createFolder, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + data: [{ id: 1, parentId: 0, pos: 0, title: 'Root' }], + total: 1, + page: 1, + limit: 25, + } + + let lastUrl: URL | null = null + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/tcase/folders`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastUrl = new URL(request.url) + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastUrl = null + lastParams = {} + }) + + test('lists folders', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes sort-field as query parameter', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--sort-field', + 'created_at', + '--sort-order', + 'desc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastUrl).not.toBeNull() + expect(lastUrl!.searchParams.get('sortField')).toEqual('created_at') + expect(lastUrl!.searchParams.get('sortOrder')).toEqual('desc') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects invalid sort-field value', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--sort-field', 'invalid_field'), + /sort-field|choices|invalid_field/i + ) + }) +}) + +test('lists folders on live server', { tags: ['live'] }, async ({ project }) => { + await createFolder(project.code) + const result = await runCli>( + 'api', + 'folders', + 'list', + '--project-code', + project.code + ) + expect(result).toHaveProperty('data') + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBeGreaterThanOrEqual(1) +}) diff --git a/src/tests/api/milestones/create.spec.ts b/src/tests/api/milestones/create.spec.ts new file mode 100644 index 0000000..61cefcc --- /dev/null +++ b/src/tests/api/milestones/create.spec.ts @@ -0,0 +1,77 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect, vi } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/milestone`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 1 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a milestone', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--title', 'v1.0') + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual({ id: 1 }) + expect(lastRequest).toEqual({ title: 'v1.0' }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--title', 'Test']) + + test('rejects empty title', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect(runCommand('--project-code', 'PRJ', '--title', '')).rejects.toThrow( + 'process.exit' + ) + const errorOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(errorOutput).toMatch(/--title.*must not be empty/) + } finally { + exitSpy.mockRestore() + errorSpy.mockRestore() + } + }) +}) + +test('creates a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli<{ id: number }>( + 'api', + 'milestones', + 'create', + '--project-code', + project.code, + '--title', + 'v1.0' + ) + expect(created).toHaveProperty('id') + expect(typeof created.id).toBe('number') +}) diff --git a/src/tests/api/milestones/list.spec.ts b/src/tests/api/milestones/list.spec.ts new file mode 100644 index 0000000..747c270 --- /dev/null +++ b/src/tests/api/milestones/list.spec.ts @@ -0,0 +1,62 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Milestone } from '../../../api/milestones' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'list', ...args) + +describe('mocked', () => { + const mockMilestones = [{ id: 1, title: 'v1.0', archived: false }] + + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/milestone`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ milestones: mockMilestones }) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists milestones in a project', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockMilestones) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +test('creates and lists milestones on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli<{ id: number }>( + 'api', + 'milestones', + 'create', + '--project-code', + project.code, + '--title', + 'v1.0' + ) + const list = await runCli( + 'api', + 'milestones', + 'list', + '--project-code', + project.code + ) + expect(list.some((m) => m.id === created.id)).toBe(true) +}) diff --git a/src/tests/api/projects/create.spec.ts b/src/tests/api/projects/create.spec.ts new file mode 100644 index 0000000..8b320d6 --- /dev/null +++ b/src/tests/api/projects/create.spec.ts @@ -0,0 +1,107 @@ +import { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'projects', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + + useMockServer( + http.post(`${baseURL}/api/public/v0/project`, async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastRequest = await request.json() + return HttpResponse.json({ id: 'p1', code: 'PRJ', title: 'My Project' }) + }) + ) + + beforeEach(() => { + lastRequest = null + }) + + describe('successful requests', () => { + test('creates a project with required fields', async () => { + const result = await runCommand('--code', 'PRJ', '--title', 'My Project') + expect(lastRequest).toEqual({ code: 'PRJ', title: 'My Project' }) + expect(result).toEqual({ id: 'p1', code: 'PRJ', title: 'My Project' }) + }) + + test('creates a project with all optional fields', async () => { + await runCommand( + '--code', + 'PRJ', + '--title', + 'My Project', + '--links', + '[{"text": "Docs", "url": "https://example.com"}]', + '--overview-title', + 'Overview', + '--overview-description', + 'Desc' + ) + expect(lastRequest).toEqual({ + code: 'PRJ', + title: 'My Project', + links: [{ text: 'Docs', url: 'https://example.com' }], + overviewTitle: 'Overview', + overviewDescription: 'Desc', + }) + }) + }) +}) + +describe('validation errors', () => { + test('rejects code shorter than 2 characters', async () => { + await expectValidationError( + () => runCommand('--code', 'X', '--title', 'Test'), + /--code.*must be at least 2 characters/ + ) + }) + + test('rejects code longer than 5 characters', async () => { + await expectValidationError( + () => runCommand('--code', 'ABCDEF', '--title', 'Test'), + /--code.*must be at most 5 characters/ + ) + }) + + test('rejects non-alphanumeric code', async () => { + await expectValidationError( + () => runCommand('--code', 'PR-J', '--title', 'Test'), + /--code.*must contain only latin letters and digits/ + ) + }) + + testRejectsInvalidIdentifier(runCommand, 'code', 'code', ['--title', 'Test']) + + test('rejects links with old "title" field name', async () => { + await expectValidationError( + () => + runCommand( + '--code', + 'PRJ', + '--title', + 'Test', + '--links', + '[{"title": "Docs", "url": "https://example.com"}]' + ), + /Validation failed/ + ) + }) + + test('rejects overview-title exceeding 255 characters', async () => { + await expectValidationError( + () => runCommand('--code', 'PRJ', '--title', 'Test', '--overview-title', 'x'.repeat(256)), + /--overview-title.*must be at most 255 characters/ + ) + }) +}) diff --git a/src/tests/api/projects/get.spec.ts b/src/tests/api/projects/get.spec.ts new file mode 100644 index 0000000..57a844f --- /dev/null +++ b/src/tests/api/projects/get.spec.ts @@ -0,0 +1,40 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'projects', 'get', ...args) + +describe('mocked', () => { + const mockProject = { id: 'p1', code: 'PRJ', title: 'Project 1' } + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:codeOrId`, ({ params, request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + expect(params['codeOrId']).toEqual('PRJ') + return HttpResponse.json(mockProject) + }) + ) + + test('gets a project by code', async () => { + const result = await runCommand('--project-code', 'PRJ') + expect(result).toEqual(mockProject) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +test('gets a project on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(result).toHaveProperty('code', project.code) + expect(result).toHaveProperty('id') + expect(result).toHaveProperty('title') +}) diff --git a/src/tests/api/projects/list.spec.ts b/src/tests/api/projects/list.spec.ts new file mode 100644 index 0000000..eff68b9 --- /dev/null +++ b/src/tests/api/projects/list.spec.ts @@ -0,0 +1,35 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect } from 'vitest' +import type { Project } from '../../../api/projects' +import { test, baseURL, token, useMockServer, runCli } from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'projects', 'list', ...args) + +describe('mocked', () => { + const mockProjects = [ + { id: 'p1', code: 'PRJ', title: 'Project 1' }, + { id: 'p2', code: 'TST', title: 'Project 2' }, + ] + + useMockServer( + http.get(`${baseURL}/api/public/v0/project`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + return HttpResponse.json({ projects: mockProjects }) + }) + ) + + test('lists all projects', async () => { + const result = await runCommand() + expect(result).toEqual(mockProjects) + }) +}) + +test('lists projects on live server', { tags: ['live'] }, async () => { + const result = await runCommand() + expect(Array.isArray(result)).toBe(true) + for (const p of result as Project[]) { + expect(p).toHaveProperty('id') + expect(p).toHaveProperty('code') + expect(p).toHaveProperty('title') + } +}) diff --git a/src/tests/api/requirements/list.spec.ts b/src/tests/api/requirements/list.spec.ts new file mode 100644 index 0000000..af4d02f --- /dev/null +++ b/src/tests/api/requirements/list.spec.ts @@ -0,0 +1,72 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Requirement } from '../../../api/requirements' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'requirements', 'list', ...args) + +describe('mocked', () => { + const mockRequirements = [{ id: 1, text: 'User can login' }] + + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/requirement`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ requirements: mockRequirements }) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists requirements', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockRequirements) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +describe('live', { tags: ['live'] }, () => { + test('lists requirements', async ({ project }) => { + const result = await runCli( + 'api', + 'requirements', + 'list', + '--project-code', + project.code + ) + expect(Array.isArray(result)).toBe(true) + }) + + test('lists requirements with include', async ({ project }) => { + const result = await runCli( + 'api', + 'requirements', + 'list', + '--project-code', + project.code, + '--include', + 'tcaseCount' + ) + expect(Array.isArray(result)).toBe(true) + for (const req of result) { + expect(req).toHaveProperty('tcaseCount') + expect(typeof req.tcaseCount).toBe('number') + } + }) +}) diff --git a/src/tests/api/results/batch-create.spec.ts b/src/tests/api/results/batch-create.spec.ts new file mode 100644 index 0000000..ba560b6 --- /dev/null +++ b/src/tests/api/results/batch-create.spec.ts @@ -0,0 +1,159 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { RunTCase } from '../../../api/runs' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'results', 'batch-create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/result/batch`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ ids: [1, 2] }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('batch creates results with links', async () => { + await runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--items', + '[{"tcaseId": "tc1", "status": "passed", "links": [{"text": "Log", "url": "https://ci.example.com/1"}]}]' + ) + expect(lastParams.projectCode).toBe('PRJ') + expect(lastParams.runId).toBe('1') + expect(lastRequest).toEqual({ + items: [ + { + tcaseId: 'tc1', + status: 'passed', + links: [{ text: 'Log', url: 'https://ci.example.com/1' }], + }, + ], + }) + }) + + test('batch creates results', async () => { + const result = await runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--items', + '[{"tcaseId": "tc1", "status": "passed"}, {"tcaseId": "tc2", "status": "failed"}]' + ) + expect(lastParams.projectCode).toBe('PRJ') + expect(lastParams.runId).toBe('1') + expect(lastRequest).toEqual({ + items: [ + { tcaseId: 'tc1', status: 'passed' }, + { tcaseId: 'tc2', status: 'failed' }, + ], + }) + expect(result).toEqual({ ids: [1, 2] }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--items', + JSON.stringify([{ tcaseId: 'tc1', status: 'passed' }]), + ]) + + test('rejects items with invalid status', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--items', + JSON.stringify([{ tcaseId: 'tc1', status: 'invalid-status' }]) + ), + /Invalid enum value/ + ) + }) + + test('rejects items missing required fields', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--items', + JSON.stringify([{ status: 'passed' }]) + ), + /tcaseId: Required/ + ) + }) + + test('rejects empty items array', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--items', '[]'), + /Must contain at least one result item/ + ) + }) +}) + +test('batch creates results on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const runObj = await createRun(project.code, [tcase.id]) + const tcases = await runCli( + 'api', + 'runs', + 'tcases', + 'list', + '--project-code', + project.code, + '--run-id', + String(runObj.id) + ) + const tcaseId = tcases[0].id + const result = await runCli<{ ids: number[] }>( + 'api', + 'results', + 'batch-create', + '--project-code', + project.code, + '--run-id', + String(runObj.id), + '--items', + JSON.stringify([{ tcaseId, status: 'passed' }]) + ) + expect(result).toHaveProperty('ids') + expect(Array.isArray(result.ids)).toBe(true) +}) diff --git a/src/tests/api/results/create.spec.ts b/src/tests/api/results/create.spec.ts new file mode 100644 index 0000000..3793272 --- /dev/null +++ b/src/tests/api/results/create.spec.ts @@ -0,0 +1,152 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { CreateResultResponse } from '../../../api/results' +import type { RunTCase } from '../../../api/runs' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'results', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase/:tcaseId/result`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 1 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a result', async () => { + const result = await runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--tcase-id', + 'tc1', + '--status', + 'passed' + ) + expect(lastParams.projectCode).toBe('PRJ') + expect(lastParams.runId).toBe('1') + expect(lastParams.tcaseId).toBe('tc1') + expect(lastRequest).toEqual({ status: 'passed' }) + expect(result).toEqual({ id: 1 }) + }) + + test('creates a result with links using text field', async () => { + await runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--tcase-id', + 'tc1', + '--status', + 'passed', + '--links', + '[{"text": "CI Log", "url": "https://ci.example.com/123"}]' + ) + expect(lastParams.projectCode).toBe('PRJ') + expect(lastParams.runId).toBe('1') + expect(lastParams.tcaseId).toBe('tc1') + expect(lastRequest).toEqual({ + status: 'passed', + links: [{ text: 'CI Log', url: 'https://ci.example.com/123' }], + }) + }) + + test('creates a result with optional fields', async () => { + await runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--tcase-id', + 'tc1', + '--status', + 'failed', + '--comment', + 'Bug found', + '--time-taken', + '5000' + ) + expect(lastParams.projectCode).toBe('PRJ') + expect(lastParams.runId).toBe('1') + expect(lastParams.tcaseId).toBe('tc1') + expect(lastRequest).toEqual({ status: 'failed', comment: 'Bug found', timeTaken: 5000 }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--tcase-id', + 'tc1', + '--status', + 'passed', + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--run-id', + '1', + '--status', + 'passed', + ]) +}) + +test('creates a result on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const runObj = await createRun(project.code, [tcase.id]) + const tcases = await runCli( + 'api', + 'runs', + 'tcases', + 'list', + '--project-code', + project.code, + '--run-id', + String(runObj.id) + ) + const tcaseId = tcases[0].id + const result = await runCli( + 'api', + 'results', + 'create', + '--project-code', + project.code, + '--run-id', + String(runObj.id), + '--tcase-id', + tcaseId, + '--status', + 'passed' + ) + expect(result).toHaveProperty('id') +}) diff --git a/src/tests/api/runs/clone.spec.ts b/src/tests/api/runs/clone.spec.ts new file mode 100644 index 0000000..87f3881 --- /dev/null +++ b/src/tests/api/runs/clone.spec.ts @@ -0,0 +1,98 @@ +import type { CloneRunResponse } from '../../../api/runs' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect, vi } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'clone', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/clone`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 99 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('clones a run', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--title', + 'Cloned Run' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runId: 42, title: 'Cloned Run' }) + expect(result).toEqual({ id: 99 }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--title', + 'Test', + ]) + + test('rejects empty title', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect( + runCommand('--project-code', 'PRJ', '--run-id', '42', '--title', '') + ).rejects.toThrow('process.exit') + const errorOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(errorOutput).toMatch(/--title.*must not be empty/) + } finally { + exitSpy.mockRestore() + errorSpy.mockRestore() + } + }) +}) + +test('clones a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const cloned = await runCli( + 'api', + 'runs', + 'clone', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--title', + 'Cloned Run' + ) + expect(cloned).toHaveProperty('id') + expect(cloned.id).not.toBe(run.id) +}) diff --git a/src/tests/api/runs/close.spec.ts b/src/tests/api/runs/close.spec.ts new file mode 100644 index 0000000..b1465e0 --- /dev/null +++ b/src/tests/api/runs/close.spec.ts @@ -0,0 +1,65 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' +import { MessageResponse } from '../../../api/schemas' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'close', ...args) + +const expectedResponse = { message: 'Run closed' } + +describe('mocked', () => { + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/close`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ message: 'Run closed' }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('closes a run', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(expectedResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) +}) + +test('closes a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const result = await runCli( + 'api', + 'runs', + 'close', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result).toEqual(expectedResponse) +}) diff --git a/src/tests/api/runs/create.spec.ts b/src/tests/api/runs/create.spec.ts new file mode 100644 index 0000000..f3ca580 --- /dev/null +++ b/src/tests/api/runs/create.spec.ts @@ -0,0 +1,422 @@ +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { CreateRunRequest, CreateRunResponse } from '../../../api/runs' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'create', ...args) + +describe('mocked', () => { + let lastCreateRunRequest: CreateRunRequest | null = null + let lastParams: PathParams = {} + + useMockServer( + http.post(`${baseURL}/api/public/v0/project/:projectCode/run`, async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastCreateRunRequest = (await request.json()) as CreateRunRequest + return HttpResponse.json({ id: 42 }) + }) + ) + + beforeEach(() => { + lastCreateRunRequest = null + lastParams = {} + }) + + describe('successful requests', () => { + test('creates a static run with tcaseIds', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'Sprint 1', + '--type', + 'static', + '--query-plans', + '[{"tcaseIds": ["abc", "def"]}]' + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'Sprint 1', + type: 'static', + queryPlans: [{ tcaseIds: ['abc', 'def'] }], + }) + expect(result).toEqual({ id: 42 }) + }) + + test('creates a static_struct run with folderIds and priorities', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'Structured Run', + '--type', + 'static_struct', + '--query-plans', + '[{"folderIds": [1, 2], "priorities": ["high"]}]' + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'Structured Run', + type: 'static_struct', + queryPlans: [{ folderIds: [1, 2], priorities: ['high'] }], + }) + }) + + test('creates a live run with multiple query plans', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'Live Run', + '--type', + 'live', + '--query-plans', + '[{"folderIds": [1]}, {"tagIds": [10], "priorities": ["low"]}]' + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'Live Run', + type: 'live', + queryPlans: [{ folderIds: [1] }, { tagIds: [10], priorities: ['low'] }], + }) + }) + + test('reads --query-plans from a file using @filename', async ({ project }) => { + const tmpDir = mkdtempSync(join(tmpdir(), 'qas-test-')) + const filePath = join(tmpDir, 'plans.json') + writeFileSync(filePath, JSON.stringify([{ tcaseIds: ['x1', 'x2'] }])) + + try { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'From File', + '--type', + 'static', + '--query-plans', + `@${filePath}` + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'From File', + type: 'static', + queryPlans: [{ tcaseIds: ['x1', 'x2'] }], + }) + expect(result).toEqual({ id: 42 }) + } finally { + rmSync(tmpDir, { recursive: true }) + } + }) + + test('includes optional milestone-id, configuration-id, and assignment-id', async ({ + project, + }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'With Options', + '--type', + 'static', + '--milestone-id', + '5', + '--configuration-id', + 'cfg-1', + '--assignment-id', + '10', + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'With Options', + type: 'static', + milestoneId: 5, + configurationId: 'cfg-1', + assignmentId: 10, + queryPlans: [{ tcaseIds: ['abc'] }], + }) + }) + + test('includes optional description', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'With Description', + '--type', + 'static', + '--description', + 'Some description', + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ) + + expect(lastParams.projectCode).toBe(project.code) + expect(lastCreateRunRequest).toEqual({ + title: 'With Description', + type: 'static', + description: 'Some description', + queryPlans: [{ tcaseIds: ['abc'] }], + }) + }) + }) +}) + +describe('validation errors', () => { + test('rejects empty title', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + '', + '--type', + 'static', + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ), + /--title.*must not be empty/ + ) + }) + + test('rejects title exceeding 255 characters', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'x'.repeat(256), + '--type', + 'static', + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ), + /--title.*must be at most 255 characters/ + ) + }) + + test('rejects description exceeding 512 characters', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Valid', + '--type', + 'static', + '--description', + 'x'.repeat(513), + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ), + /--description.*must be at most 512 characters/ + ) + }) + + test('rejects invalid JSON in --query-plans', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + 'not-json' + ), + /Failed to parse --query-plans as JSON/ + ) + }) + + test('rejects empty query plans array', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[]' + ), + /Must contain at least one query plan/ + ) + }) + + test('rejects empty query plan object', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[{}]' + ), + /must specify at least one filter/ + ) + }) + + test('rejects unknown keys in query plan (strict mode)', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[{"unknownKey": true}]' + ), + /Unrecognized key/ + ) + }) + + test('rejects invalid priority values', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[{"priorities": ["critical"]}]' + ), + /Invalid enum value.*Expected 'low' \| 'medium' \| 'high'/ + ) + }) + + test('rejects tcaseIds in live runs', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'live', + '--query-plans', + '[{"tcaseIds": ["abc"]}]' + ), + /tcaseIds is not allowed for "live" runs/ + ) + }) + + test('rejects multiple query plans for static runs', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[{"tcaseIds": ["abc"]}, {"folderIds": [1]}]' + ), + /supports exactly one query plan/ + ) + }) + + test('rejects multiple query plans for static_struct runs', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static_struct', + '--query-plans', + '[{"tcaseIds": ["abc"]}, {"folderIds": [1]}]' + ), + /supports exactly one query plan/ + ) + }) + + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + JSON.stringify([{ tcaseIds: ['abc'] }]), + ]) + + test('rejects non-integer folderIds', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'static', + '--query-plans', + '[{"folderIds": [1.5]}]' + ), + /integer/ + ) + }) +}) + +test('creates a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const created = await runCli( + 'api', + 'runs', + 'create', + '--project-code', + project.code, + '--title', + 'Live Run', + '--type', + 'static', + '--query-plans', + JSON.stringify([{ tcaseIds: [tcase.id] }]) + ) + expect(created).toHaveProperty('id') + expect(typeof created.id).toBe('number') +}) diff --git a/src/tests/api/runs/list.spec.ts b/src/tests/api/runs/list.spec.ts new file mode 100644 index 0000000..b44fa44 --- /dev/null +++ b/src/tests/api/runs/list.spec.ts @@ -0,0 +1,64 @@ +import type { Run } from '../../../api/runs' +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'list', ...args) + +describe('mocked', () => { + const mockRuns = [{ id: 1, title: 'Run 1', type: 'static', closed: false }] + + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/run`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json({ runs: mockRuns }) + }) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('lists runs in a project', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockRuns) + }) + + test('passes filter params', async ({ project }) => { + await runCommand('--project-code', project.code, '--closed', '--limit', '10') + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams).not.toBeNull() + expect(lastSearchParams!.get('closed')).toBe('true') + expect(lastSearchParams!.get('limit')).toBe('10') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +test('lists runs on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const result = await runCli('api', 'runs', 'list', '--project-code', project.code) + expect(result.some((r) => r.id === run.id)).toBe(true) +}) diff --git a/src/tests/api/runs/tcases-get.spec.ts b/src/tests/api/runs/tcases-get.spec.ts new file mode 100644 index 0000000..e2aa263 --- /dev/null +++ b/src/tests/api/runs/tcases-get.spec.ts @@ -0,0 +1,100 @@ +import type { RunTCase } from '../../../api/runs' +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'tcases', 'get', ...args) + +describe('mocked', () => { + const mockTCase = { id: 'tc1', title: 'Login test', seq: 1, status: 'open' } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase/:tcaseId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json(mockTCase) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a test case in a run', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--tcase-id', + 'tc1' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastParams.tcaseId).toBe('tc1') + expect(result).toEqual(mockTCase) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--tcase-id', + 'tc1', + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--run-id', + '1', + ]) +}) + +test('gets a test case in a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const tcases = await runCli( + 'api', + 'runs', + 'tcases', + 'list', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + const firstTcase = tcases[0] + const result = await runCli( + 'api', + 'runs', + 'tcases', + 'get', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--tcase-id', + firstTcase.id + ) + expect(result).toHaveProperty('id', firstTcase.id) + expect(result).toHaveProperty('title') +}) diff --git a/src/tests/api/runs/tcases-list.spec.ts b/src/tests/api/runs/tcases-list.spec.ts new file mode 100644 index 0000000..bf9f61e --- /dev/null +++ b/src/tests/api/runs/tcases-list.spec.ts @@ -0,0 +1,107 @@ +import type { RunTCase } from '../../../api/runs' +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'tcases', 'list', ...args) + +describe('mocked', () => { + const mockTCases = [{ id: 'tc1', title: 'Test', seq: 1 }] + const mockResponse = { tcases: mockTCases } + + let lastUrl: URL | null = null + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastUrl = new URL(request.url) + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastUrl = null + lastParams = {} + }) + + test('lists test cases in a run', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockTCases) + }) + + test('passes include param as query parameter', async ({ project }) => { + await runCommand('--project-code', project.code, '--run-id', '42', '--include', 'folder') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastUrl).not.toBeNull() + expect(lastUrl!.searchParams.get('include')).toEqual('folder') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) +}) + +describe('live', { tags: ['live'] }, () => { + test('lists test cases in a run', async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const result = await runCli( + 'api', + 'runs', + 'tcases', + 'list', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + + test('lists test cases in a run with include', async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const result = await runCli( + 'api', + 'runs', + 'tcases', + 'list', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--include', + 'folder' + ) + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + const rtcase = result[0] + expect(rtcase).toHaveProperty('folder') + expect(rtcase.folder).toHaveProperty('id') + expect(rtcase.folder).toHaveProperty('title') + }) +}) diff --git a/src/tests/api/settings/list-statuses.spec.ts b/src/tests/api/settings/list-statuses.spec.ts new file mode 100644 index 0000000..8a7fd7f --- /dev/null +++ b/src/tests/api/settings/list-statuses.spec.ts @@ -0,0 +1,25 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect } from 'vitest' +import { test, baseURL, token, useMockServer, runCli } from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'settings', 'list-statuses', ...args) + +describe('mocked', () => { + const mockStatuses = [ + { id: 'passed', name: 'Passed', color: 'blue', isActive: true }, + { id: 'custom1', name: 'Retest', color: 'orange', isActive: true }, + ] + + useMockServer( + http.get(`${baseURL}/api/public/v0/settings/preferences/status`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + return HttpResponse.json({ statuses: mockStatuses }) + }) + ) + + test('lists result statuses', async () => { + const result = await runCommand() + expect(result).toEqual(mockStatuses) + }) +}) diff --git a/src/tests/api/settings/update-statuses.spec.ts b/src/tests/api/settings/update-statuses.spec.ts new file mode 100644 index 0000000..1c725dc --- /dev/null +++ b/src/tests/api/settings/update-statuses.spec.ts @@ -0,0 +1,65 @@ +import { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { test, baseURL, token, useMockServer, runCli, expectValidationError } from '../test-helper' +import type { Status } from '../../../api/settings' + +const runCommand = (...args: string[]) => + runCli('api', 'settings', 'update-statuses', ...args) + +const listStatuses = () => runCli('api', 'settings', 'list-statuses') + +describe('mocked', () => { + let lastRequest: unknown = null + + useMockServer( + http.post(`${baseURL}/api/public/v0/settings/preferences/status`, async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastRequest = await request.json() + return HttpResponse.json({ message: 'Statuses updated' }) + }) + ) + + beforeEach(() => { + lastRequest = null + }) + + test('updates custom statuses', async () => { + const statuses = [{ id: 'custom1', name: 'Retest', color: 'orange', isActive: true }] + await runCommand('--statuses', JSON.stringify(statuses)) + expect(lastRequest).toEqual({ + statuses, + }) + }) +}) + +describe('validation errors', () => { + test('rejects hex color values', async () => { + const statuses = [{ id: 'custom1', name: 'Retest', color: '#FF9800', isActive: true }] + await expectValidationError( + () => runCommand('--statuses', JSON.stringify(statuses)), + /color.*must be one of/i + ) + }) +}) + +describe('live', { tags: ['live'] }, () => { + test('statuses use named colors', async () => { + const validColors = new Set([ + 'blue', + 'gray', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'indigo', + 'purple', + 'pink', + ]) + const statuses = await listStatuses() + expect(statuses.length).toBeGreaterThan(0) + for (const status of statuses) { + expect(validColors).toContain(status.color) + } + }) +}) diff --git a/src/tests/api/shared-preconditions/get.spec.ts b/src/tests/api/shared-preconditions/get.spec.ts new file mode 100644 index 0000000..3986030 --- /dev/null +++ b/src/tests/api/shared-preconditions/get.spec.ts @@ -0,0 +1,45 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-preconditions', 'get', ...args) + +describe('mocked', () => { + const mockData = { id: 1, title: 'User is logged in' } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json(mockData) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a shared precondition', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(result).toEqual(mockData) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1']) +}) diff --git a/src/tests/api/shared-preconditions/list.spec.ts b/src/tests/api/shared-preconditions/list.spec.ts new file mode 100644 index 0000000..de28c6a --- /dev/null +++ b/src/tests/api/shared-preconditions/list.spec.ts @@ -0,0 +1,60 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { SharedPrecondition } from '../../../api/shared-preconditions' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-preconditions', 'list', ...args) + +describe('mocked', () => { + const mockData = [{ id: 1, title: 'User is logged in' }] + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json(mockData) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists shared preconditions', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockData) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +describe('live', { tags: ['live'] }, () => { + test('lists shared preconditions', async ({ project }) => { + const result = await runCli( + 'api', + 'shared-preconditions', + 'list', + '--project-code', + project.code + ) + // Fresh project has no shared preconditions; API returns null for empty list + // Currently there's no way to create shared preconditions via the public API + expect(result === null || Array.isArray(result)).toBe(true) + }) +}) diff --git a/src/tests/api/shared-steps/get.spec.ts b/src/tests/api/shared-steps/get.spec.ts new file mode 100644 index 0000000..ff3d014 --- /dev/null +++ b/src/tests/api/shared-steps/get.spec.ts @@ -0,0 +1,45 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-steps', 'get', ...args) + +describe('mocked', () => { + const mockData = { id: 1, title: 'Login steps' } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/shared-step/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json(mockData) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a shared step', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(result).toEqual(mockData) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1']) +}) diff --git a/src/tests/api/shared-steps/list.spec.ts b/src/tests/api/shared-steps/list.spec.ts new file mode 100644 index 0000000..5ed8daf --- /dev/null +++ b/src/tests/api/shared-steps/list.spec.ts @@ -0,0 +1,72 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { SharedStep } from '../../../api/shared-steps' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-steps', 'list', ...args) + +describe('mocked', () => { + const mockData = [{ id: 1, title: 'Login steps' }] + + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/shared-step`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ sharedSteps: mockData }) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists shared steps', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockData) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +describe('live', { tags: ['live'] }, () => { + test('lists shared steps', async ({ project }) => { + const result = await runCli( + 'api', + 'shared-steps', + 'list', + '--project-code', + project.code + ) + expect(Array.isArray(result)).toBe(true) + }) + + test('lists shared steps with include', async ({ project }) => { + const result = await runCli( + 'api', + 'shared-steps', + 'list', + '--project-code', + project.code, + '--include', + 'tcaseCount' + ) + expect(Array.isArray(result)).toBe(true) + for (const step of result) { + expect(step).toHaveProperty('tcaseCount') + expect(typeof step.tcaseCount).toBe('number') + } + }) +}) diff --git a/src/tests/api/tags/list.spec.ts b/src/tests/api/tags/list.spec.ts new file mode 100644 index 0000000..c339df2 --- /dev/null +++ b/src/tests/api/tags/list.spec.ts @@ -0,0 +1,43 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'tags', 'list', ...args) + +describe('mocked', () => { + const mockTags = [ + { id: 1, title: 'smoke' }, + { id: 2, title: 'regression' }, + ] + + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/tag`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json({ tags: mockTags }) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists tags in a project', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockTags) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) diff --git a/src/tests/api/test-cases/count.spec.ts b/src/tests/api/test-cases/count.spec.ts new file mode 100644 index 0000000..0637c95 --- /dev/null +++ b/src/tests/api/test-cases/count.spec.ts @@ -0,0 +1,66 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'count', ...args) + +describe('mocked', () => { + let lastUrl: URL | null = null + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/tcase/count`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastUrl = new URL(request.url) + return HttpResponse.json({ count: 42 }) + }) + ) + + afterEach(() => { + lastUrl = null + lastParams = {} + }) + + test('counts test cases', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual({ count: 42 }) + }) + + test('passes recursive param as query parameter', async ({ project }) => { + await runCommand('--project-code', project.code, '--folders', '1,2', '--recursive') + expect(lastParams.projectCode).toBe(project.code) + expect(lastUrl).not.toBeNull() + expect(lastUrl!.searchParams.get('recursive')).toEqual('true') + expect(lastUrl!.searchParams.getAll('folders')).toEqual(['1', '2']) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +test('counts test cases on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + await createTCase(project.code, folderId) + const result = await runCli<{ count: number }>( + 'api', + 'test-cases', + 'count', + '--project-code', + project.code + ) + expect(result.count).toBe(1) +}) diff --git a/src/tests/api/test-cases/create.spec.ts b/src/tests/api/test-cases/create.spec.ts new file mode 100644 index 0000000..f9661c6 --- /dev/null +++ b/src/tests/api/test-cases/create.spec.ts @@ -0,0 +1,502 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect, beforeAll, afterAll } from 'vitest' +import type { TCase } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + let tempDir: string + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tcase`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 'tc1', seq: 1 }) + } + ) + ) + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'qas-create-tcase-')) + }) + + afterAll(() => { + rmSync(tempDir, { recursive: true }) + }) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a test case', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--body', + '{"title": "Login test", "type": "standalone", "folderId": 1, "priority": "high"}' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Login test', + type: 'standalone', + folderId: 1, + priority: 'high', + }) + expect(result).toEqual({ id: 'tc1', seq: 1 }) + }) + + test('creates a test case with all optional fields using correct field names', async ({ + project, + }) => { + const body = JSON.stringify({ + title: 'Full test', + type: 'standalone', + folderId: 1, + priority: 'high', + tags: ['smoke', 'regression'], + isDraft: true, + steps: [{ description: 'Click button', expected: 'Dialog opens' }], + precondition: { text: 'User is logged in' }, + }) + await runCommand('--project-code', project.code, '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Full test', + type: 'standalone', + folderId: 1, + priority: 'high', + tags: ['smoke', 'regression'], + isDraft: true, + steps: [{ description: 'Click button', expected: 'Dialog opens' }], + precondition: { text: 'User is logged in' }, + }) + }) + + test('creates a test case with multiple steps', async ({ project }) => { + const body = JSON.stringify({ + title: 'Multi-step test', + type: 'standalone', + folderId: 1, + priority: 'medium', + steps: [ + { description: 'Navigate to login page', expected: 'Login page is displayed' }, + { description: 'Enter credentials', expected: 'Fields are filled' }, + { description: 'Click submit', expected: 'User is logged in' }, + ], + }) + await runCommand('--project-code', project.code, '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Multi-step test', + type: 'standalone', + folderId: 1, + priority: 'medium', + steps: [ + { description: 'Navigate to login page', expected: 'Login page is displayed' }, + { description: 'Enter credentials', expected: 'Fields are filled' }, + { description: 'Click submit', expected: 'User is logged in' }, + ], + }) + }) + + test('creates a test case with body from @file', async ({ project }) => { + const filePath = join(tempDir, 'tcase.json') + writeFileSync( + filePath, + JSON.stringify({ + title: 'From file', + type: 'standalone', + folderId: 1, + priority: 'low', + }) + ) + await runCommand('--project-code', project.code, '--body', `@${filePath}`) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'From file', + type: 'standalone', + folderId: 1, + priority: 'low', + }) + }) + + test('creates a test case with sharedPreconditionId', async ({ project }) => { + const body = JSON.stringify({ + title: 'With shared precondition', + type: 'standalone', + folderId: 1, + priority: 'medium', + precondition: { sharedPreconditionId: 42 }, + }) + await runCommand('--project-code', project.code, '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'With shared precondition', + type: 'standalone', + folderId: 1, + priority: 'medium', + precondition: { sharedPreconditionId: 42 }, + }) + }) + + test('creates a test case with requirements and links', async ({ project }) => { + const body = JSON.stringify({ + title: 'With links', + type: 'standalone', + folderId: 1, + priority: 'low', + requirements: [{ text: 'REQ-1', url: 'https://example.com/req/1' }], + links: [{ text: 'Docs', url: 'https://example.com/docs' }], + }) + await runCommand('--project-code', project.code, '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'With links', + type: 'standalone', + folderId: 1, + priority: 'low', + requirements: [{ text: 'REQ-1', url: 'https://example.com/req/1' }], + links: [{ text: 'Docs', url: 'https://example.com/docs' }], + }) + }) + + test('creates a test case using individual fields', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'CLI Test', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'high' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'CLI Test', + type: 'standalone', + folderId: 1, + priority: 'high', + }) + }) + + test('individual fields override json body', async ({ project }) => { + const body = JSON.stringify({ + title: 'From body', + type: 'standalone', + folderId: 1, + priority: 'low', + precondition: { text: 'Body precondition' }, + }) + await runCommand( + '--project-code', + project.code, + '--body', + body, + '--title', + 'Overridden', + '--priority', + 'high' + ) + expect(lastRequest).toEqual({ + title: 'Overridden', + type: 'standalone', + folderId: 1, + priority: 'high', + precondition: { text: 'Body precondition' }, + }) + }) + + test('--precondition-text overrides body precondition', async ({ project }) => { + const body = JSON.stringify({ + title: 'Test', + type: 'standalone', + folderId: 1, + priority: 'high', + precondition: { text: 'Body precondition' }, + }) + await runCommand( + '--project-code', + project.code, + '--body', + body, + '--precondition-text', + 'CLI precondition' + ) + expect(lastRequest).toEqual({ + title: 'Test', + type: 'standalone', + folderId: 1, + priority: 'high', + precondition: { text: 'CLI precondition' }, + }) + }) + + test('--precondition-id overrides body precondition', async ({ project }) => { + const body = JSON.stringify({ + title: 'Test', + type: 'standalone', + folderId: 1, + priority: 'high', + precondition: { text: 'Body precondition' }, + }) + await runCommand('--project-code', project.code, '--body', body, '--precondition-id', '42') + expect(lastRequest).toEqual({ + title: 'Test', + type: 'standalone', + folderId: 1, + priority: 'high', + precondition: { sharedPreconditionId: 42 }, + }) + }) + + test('creates a template test case with custom fields', async ({ project }) => { + const body = { + title: 'With custom fields', + type: 'template', + folderId: 1, + priority: 'medium', + customFields: { + field1: { isDefault: false, value: 'custom value' }, + field2: { isDefault: true }, + }, + } + await runCommand('--project-code', project.code, '--body', JSON.stringify(body)) + expect(lastRequest).toEqual(body) + }) + + test('creates a template test case with parameter values', async ({ project }) => { + const body = { + title: 'Login ${browser}', + type: 'template', + folderId: 1, + priority: 'medium', + parameterValues: [ + { values: { browser: 'Chrome', os: 'Windows' } }, + { values: { browser: 'Firefox', os: 'Linux' } }, + ], + filledTCaseTitleSuffixParams: ['browser'], + } + await runCommand('--project-code', project.code, '--body', JSON.stringify(body)) + expect(lastRequest).toEqual(body) + }) + + test('creates a test case with --custom-fields option', async ({ project }) => { + const customFields = { field1: { isDefault: false, value: 'test' } } + await runCommand( + '--project-code', + project.code, + '--title', + 'With CF option', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'high', + '--custom-fields', + JSON.stringify(customFields) + ) + expect(lastRequest).toEqual({ + title: 'With CF option', + type: 'standalone', + folderId: 1, + priority: 'high', + customFields, + }) + }) + + test('creates a template test case with --parameter-values option', async ({ project }) => { + const parameterValues = [{ values: { browser: 'Chrome' } }] + await runCommand( + '--project-code', + project.code, + '--title', + 'Login ${browser}', + '--type', + 'template', + '--folder-id', + '1', + '--priority', + 'high', + '--parameter-values', + JSON.stringify(parameterValues) + ) + expect(lastRequest).toEqual({ + title: 'Login ${browser}', + type: 'template', + folderId: 1, + priority: 'high', + parameterValues, + }) + }) + + test('creates a test case with individual fields and --tags, --is-draft, --steps', async ({ + project, + }) => { + await runCommand( + '--project-code', + project.code, + '--title', + 'Tagged test', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'medium', + '--tags', + 'smoke,regression', + '--is-draft', + '--steps', + '[{"description": "Step 1", "expected": "Result 1"}]' + ) + expect(lastRequest).toEqual({ + title: 'Tagged test', + type: 'standalone', + folderId: 1, + priority: 'medium', + tags: ['smoke', 'regression'], + isDraft: true, + steps: [{ description: 'Step 1', expected: 'Result 1' }], + }) + }) +}) + +test('creates a test case on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const created = await runCommand<{ id: string; seq: number }>( + '--project-code', + project.code, + '--body', + JSON.stringify({ + title: 'Live Test Case', + type: 'standalone', + folderId, + priority: 'medium', + }) + ) + expect(typeof created.id).toBe('string') + expect(created.seq).toBe(1) +}) + +test( + 'creates a template test case with parameter values on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const body = { + title: 'Login ${browser}', + type: 'template' as const, + folderId, + priority: 'medium' as const, + parameterValues: [{ values: { browser: 'Chrome' } }, { values: { browser: 'Firefox' } }], + filledTCaseTitleSuffixParams: ['browser'], + } + const created = await runCommand<{ id: string; seq: number }>( + '--project-code', + project.code, + '--body', + JSON.stringify(body) + ) + const result = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(result.title).toBe(body.title) + expect(result.folderId).toBe(body.folderId) + expect(result).toHaveProperty('id') + } +) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--body', + JSON.stringify({ title: 'Test', type: 'standalone', folderId: 1, priority: 'high' }), + ]) + + test('rejects invalid JSON', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--body', 'not-json'), + /Failed to parse --body as JSON/ + ) + }) + + test('rejects missing required fields', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--body', '{"title": "Test"}'), + /Invalid arguments/ + ) + }) + + test('rejects --precondition-text and --precondition-id together', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'high', + '--precondition-text', + 'Some text', + '--precondition-id', + '42' + ), + /--precondition-text and --precondition-id are mutually exclusive/ + ) + }) + + test('rejects parameterValues on standalone test case', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--body', + JSON.stringify({ + title: 'Test', + type: 'standalone', + folderId: 1, + priority: 'high', + parameterValues: [{ values: { browser: 'Chrome' } }], + }) + ), + /--parameter-values.*only allowed for "template"/ + ) + }) +}) diff --git a/src/tests/api/test-cases/get.spec.ts b/src/tests/api/test-cases/get.spec.ts new file mode 100644 index 0000000..61f42f3 --- /dev/null +++ b/src/tests/api/test-cases/get.spec.ts @@ -0,0 +1,99 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { TCase } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'get', ...args) + +describe('mocked', () => { + const mockTCase = { id: 'tc1', title: 'Login test', seq: 1 } + + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/tcase/:id`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + return HttpResponse.json(mockTCase) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a test case by ID', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--tcase-id', 'tc1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('tc1') + expect(result).toEqual(mockTCase) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--tcase-id', 'tc1']) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', ['--project-code', 'PRJ']) +}) + +test('gets a test case on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const created = await createTCase(project.code, folderId) + const result = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + + // Always-present fields + expect(result.id).toBe(created.id) + expect(result).toHaveProperty('legacyId') + expect(result).toHaveProperty('version') + expect(result).toHaveProperty('type') + expect(result).toHaveProperty('title') + expect(result).toHaveProperty('seq') + expect(result).toHaveProperty('folderId', folderId) + expect(result).toHaveProperty('pos') + expect(result).toHaveProperty('priority', 'medium') + expect(result).toHaveProperty('comment') + expect(result).toHaveProperty('authorId') + expect(result).toHaveProperty('isDraft') + expect(result).toHaveProperty('isLatestVersion') + expect(result).toHaveProperty('isEmpty') + expect(result).toHaveProperty('createdAt') + expect(result).toHaveProperty('updatedAt') + + // Precondition object + expect(result).toHaveProperty('precondition') + expect(result.precondition).toHaveProperty('id') + expect(result.precondition).toHaveProperty('type') + expect(result.precondition).toHaveProperty('text') + expect(result.precondition).toHaveProperty('version') + expect(result.precondition).toHaveProperty('isLatest') + expect(result.precondition).toHaveProperty('createdAt') + expect(result.precondition).toHaveProperty('updatedAt') + + // Array fields present in get response (may be null for empty arrays) + expect('files' in result).toBe(true) + expect('links' in result).toBe(true) + expect('steps' in result).toBe(true) + expect('tags' in result).toBe(true) + expect('requirements' in result).toBe(true) + + // Custom fields object + expect(result).toHaveProperty('customFields') +}) diff --git a/src/tests/api/test-cases/list.spec.ts b/src/tests/api/test-cases/list.spec.ts new file mode 100644 index 0000000..1143058 --- /dev/null +++ b/src/tests/api/test-cases/list.spec.ts @@ -0,0 +1,135 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { TCase } from '../../../api/tcases' +import type { PaginatedResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { data: [{ id: 'tc1', title: 'Test' }], total: 1, page: 1, limit: 25 } + + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/tcase`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('lists test cases', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes filter params', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--folders', + '1,2', + '--priorities', + 'high', + '--search', + 'login' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams).not.toBeNull() + expect(lastSearchParams!.get('priorities')).toBe('high') + expect(lastSearchParams!.get('search')).toBe('login') + expect(lastSearchParams!.getAll('folders')).toEqual(['1', '2']) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects --page 0', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--page', '0'), + /--page.*must be greater than 0/i + ) + }) +}) + +describe('live', { tags: ['live'] }, () => { + function expectBaseFields(tcase: TCase) { + expect(tcase).toHaveProperty('id') + expect(tcase).toHaveProperty('legacyId') + expect(tcase).toHaveProperty('version') + expect(tcase).toHaveProperty('type') + expect(tcase).toHaveProperty('title') + expect(tcase).toHaveProperty('seq') + expect(tcase).toHaveProperty('folderId') + expect(tcase).toHaveProperty('pos') + expect(tcase).toHaveProperty('priority') + expect(tcase).toHaveProperty('authorId') + expect(tcase).toHaveProperty('isDraft') + expect(tcase).toHaveProperty('isLatestVersion') + expect(tcase).toHaveProperty('isEmpty') + expect(tcase).toHaveProperty('createdAt') + expect(tcase).toHaveProperty('updatedAt') + } + + test('lists test cases', async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + await createTCase(project.code, folderId) + const result = await runCli>( + 'api', + 'test-cases', + 'list', + '--project-code', + project.code + ) + + expect(result).toHaveProperty('total') + expect(result).toHaveProperty('page') + expect(result).toHaveProperty('limit') + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBe(1) + expectBaseFields(result.data[0]) + }) + + test('lists test cases with include', async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + await createTCase(project.code, folderId) + const result = await runCli>( + 'api', + 'test-cases', + 'list', + '--project-code', + project.code, + '--include', + 'tags,requirements' + ) + + expect(result.data.length).toBe(1) + const tcase = result.data[0] + expectBaseFields(tcase) + expect(Array.isArray(tcase.tags)).toBe(true) + expect(Array.isArray(tcase.requirements)).toBe(true) + }) +}) diff --git a/src/tests/api/test-cases/update.spec.ts b/src/tests/api/test-cases/update.spec.ts new file mode 100644 index 0000000..846f0bd --- /dev/null +++ b/src/tests/api/test-cases/update.spec.ts @@ -0,0 +1,340 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCase } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'update', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tcase/:id`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ message: 'Test case updated' }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('updates a test case with new field names', async ({ project }) => { + const body = JSON.stringify({ + isDraft: false, + tags: ['smoke'], + steps: [{ description: 'Step 1', expected: 'Result 1' }], + precondition: { text: 'Logged in' }, + }) + await runCommand('--project-code', project.code, '--tcase-id', 'tc1', '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('tc1') + expect(lastRequest).toEqual({ + isDraft: false, + tags: ['smoke'], + steps: [{ description: 'Step 1', expected: 'Result 1' }], + precondition: { text: 'Logged in' }, + }) + }) + + test('updates with --precondition-text', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--precondition-text', + 'User is logged in' + ) + expect(lastRequest).toEqual({ + precondition: { text: 'User is logged in' }, + }) + }) + + test('updates with --precondition-id', async ({ project }) => { + await runCommand('--project-code', project.code, '--tcase-id', 'tc1', '--precondition-id', '42') + expect(lastRequest).toEqual({ + precondition: { sharedPreconditionId: 42 }, + }) + }) + + test('updates a test case', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--body', + '{"title": "Updated"}' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('tc1') + expect(lastRequest).toEqual({ title: 'Updated' }) + }) + test('updates using individual fields', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--title', + 'Updated via CLI' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('tc1') + expect(lastRequest).toEqual({ title: 'Updated via CLI' }) + }) + + test('individual fields override json body', async ({ project }) => { + const body = JSON.stringify({ + title: 'From body', + precondition: { text: 'Body precondition' }, + priority: 'low', + }) + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--body', + body, + '--title', + 'Overridden', + '--priority', + 'high' + ) + expect(lastRequest).toEqual({ + title: 'Overridden', + precondition: { text: 'Body precondition' }, + priority: 'high', + }) + }) + + test('updates with --tags, --is-draft, --steps', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--title', + 'With extras', + '--tags', + 'smoke,e2e', + '--is-draft', + '--steps', + '[{"description": "Click", "expected": "Opens"}]' + ) + expect(lastRequest).toEqual({ + title: 'With extras', + tags: ['smoke', 'e2e'], + isDraft: true, + steps: [{ description: 'Click', expected: 'Opens' }], + }) + }) + + test('updates a test case with custom fields', async ({ project }) => { + const body = { + customFields: { + field1: { isDefault: false, value: 'updated value' }, + field2: { isDefault: true }, + }, + } + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--body', + JSON.stringify(body) + ) + expect(lastRequest).toEqual(body) + }) + + test('updates a test case with parameter values', async ({ project }) => { + const body = { + parameterValues: [ + { tcaseId: 'tc-filled-1', values: { browser: 'Chrome' } }, + { values: { browser: 'Safari' } }, + ], + } + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--body', + JSON.stringify(body) + ) + expect(lastRequest).toEqual(body) + }) + + test('updates with --custom-fields option', async ({ project }) => { + const customFields = { field1: { isDefault: false, value: 'via option' } } + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--custom-fields', + JSON.stringify(customFields) + ) + expect(lastRequest).toEqual({ customFields }) + }) + + test('updates with --parameter-values option', async ({ project }) => { + const parameterValues = [{ values: { browser: 'Edge' } }] + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--parameter-values', + JSON.stringify(parameterValues) + ) + expect(lastRequest).toEqual({ parameterValues }) + }) + + test('updates with body from @file', async ({ project }) => { + const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs') + const { join } = await import('node:path') + const { tmpdir } = await import('node:os') + const tempDir = mkdtempSync(join(tmpdir(), 'qas-update-tcase-')) + try { + const filePath = join(tempDir, 'update.json') + writeFileSync(filePath, JSON.stringify({ title: 'From file', priority: 'high' })) + await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--body', + `@${filePath}` + ) + expect(lastRequest).toEqual({ title: 'From file', priority: 'high' }) + } finally { + rmSync(tempDir, { recursive: true }) + } + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-id', + 'tc1', + '--body', + JSON.stringify({ title: 'Updated' }), + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--body', + JSON.stringify({ title: 'Updated' }), + ]) + + test('rejects --precondition-text and --precondition-id together', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--tcase-id', + 'tc1', + '--precondition-text', + 'Some text', + '--precondition-id', + '42' + ), + /--precondition-text and --precondition-id are mutually exclusive/ + ) + }) +}) + +test( + 'updates a template test case with parameter values on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const createBody = { + title: 'Template ${env}', + type: 'template' as const, + folderId, + priority: 'medium' as const, + parameterValues: [{ values: { env: 'staging' } }], + filledTCaseTitleSuffixParams: ['env'], + } + const created = await runCli<{ id: string; seq: number }>( + 'api', + 'test-cases', + 'create', + '--project-code', + project.code, + '--body', + JSON.stringify(createBody) + ) + const updatedParams = [{ values: { env: 'staging' } }, { values: { env: 'production' } }] + await runCommand( + '--project-code', + project.code, + '--tcase-id', + created.id, + '--parameter-values', + JSON.stringify(updatedParams) + ) + const result = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(result.title).toBe(createBody.title) + expect(result.folderId).toBe(createBody.folderId) + } +) + +test('updates a test case on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const created = await createTCase(project.code, folderId) + const updateResult = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--tcase-id', + created.id, + '--body', + JSON.stringify({ title: 'Updated Title' }) + ) + expect(typeof updateResult.message).toBe('string') + + const result = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(result.title).toBe('Updated Title') +}) diff --git a/src/tests/api/test-helper.ts b/src/tests/api/test-helper.ts new file mode 100644 index 0000000..348b238 --- /dev/null +++ b/src/tests/api/test-helper.ts @@ -0,0 +1,198 @@ +import { inject, test as baseTest, vi, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { setupServer, type SetupServerApi } from 'msw/node' +import type { RequestHandler } from 'msw' +import { createApi } from '../../api/index' +import { randomBytes } from 'node:crypto' +import { run } from '../../commands/main' + +declare module 'vitest' { + export interface ProvidedContext { + sessionToken: string | null + } +} + +const { QAS_TEST_URL, QAS_TEST_TOKEN, QAS_TEST_USERNAME, QAS_TEST_PASSWORD, QAS_DEV_AUTH } = + process.env + +const isRealApi = !!(QAS_TEST_URL && QAS_TEST_TOKEN && QAS_TEST_USERNAME && QAS_TEST_PASSWORD) + +function configureEnv(): { baseURL: string; token: string } { + if (isRealApi) { + const baseURL = QAS_TEST_URL! + const token = QAS_TEST_TOKEN! + process.env['QAS_URL'] = baseURL + process.env['QAS_TOKEN'] = token + return { baseURL, token } + } + const baseURL = 'https://qas.eu1.qasphere.com' + process.env['QAS_URL'] = baseURL + process.env['QAS_TOKEN'] = 'QAS_TOKEN' + return { baseURL, token: 'QAS_TOKEN' } +} + +export const { baseURL, token } = configureEnv() + +interface TestProject { + code: string + id: string +} + +function generateProjectCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + const bytes = randomBytes(4) + let code = 'T' + for (let i = 0; i < 4; i++) { + code += chars[bytes[i] % chars.length] + } + return code +} + +async function createTestProject(baseURL: string, token: string): Promise { + const api = createApi(baseURL, token) + const code = generateProjectCode() + const created = await api.projects.create({ code, title: `[CLI] Test ${code}` }) + const project = await api.projects.get(created.id) + return { code: project.code, id: project.id } +} + +async function deleteTestProject(baseURL: string, code: string): Promise { + const sessionToken = inject('sessionToken') + if (!sessionToken) { + throw new Error('No session token provided — check globalSetup login') + } + const cookies = [`session=${sessionToken}`] + if (QAS_DEV_AUTH) { + cookies.push(`_devauth=${QAS_DEV_AUTH}`) + } + await fetch(`${baseURL}/api/project/${code}`, { + method: 'DELETE', + headers: { Cookie: cookies.join('; ') }, + }) +} + +function mockConsoleLog() { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + return spy +} + +export function useMockServer(...handlers: RequestHandler[]): SetupServerApi { + const server = setupServer(...handlers) + beforeAll(() => server.listen()) + afterAll(() => server.close()) + afterEach(() => server.resetHandlers()) + return server +} + +export async function runCli(...args: string[]): Promise { + const spy = mockConsoleLog() + await run(args) + const calls = spy.mock.calls + spy.mockRestore() + if (calls.length === 0) return undefined as T + return JSON.parse(calls[calls.length - 1][0] as string) as T +} + +export async function createFolder(projectCode: string): Promise<{ ids: number[][] }> { + const uid = randomBytes(4).toString('hex') + return runCli<{ ids: number[][] }>( + 'api', + 'folders', + 'bulk-create', + '--project-code', + projectCode, + '--folders', + JSON.stringify([{ path: [`CLITest_${uid}`] }]) + ) +} + +export async function createTCase( + projectCode: string, + folderId: number +): Promise<{ id: string; seq: number }> { + const uid = randomBytes(4).toString('hex') + return runCli<{ id: string; seq: number }>( + 'api', + 'test-cases', + 'create', + '--project-code', + projectCode, + '--body', + JSON.stringify({ + title: `CLI Test ${uid}`, + type: 'standalone', + folderId, + priority: 'medium', + }) + ) +} + +export async function createRun(projectCode: string, tcaseIds: string[]): Promise<{ id: number }> { + const uid = randomBytes(4).toString('hex') + return runCli<{ id: number }>( + 'api', + 'runs', + 'create', + '--project-code', + projectCode, + '--title', + `CLI Run ${uid}`, + '--type', + 'static', + '--query-plans', + JSON.stringify([{ tcaseIds }]) + ) +} + +type ParamType = 'code' | 'int' | 'resource' + +const paramTypePatterns: Record = { + code: /must contain only latin letters and digits/, + int: /must be a positive integer/, + resource: /must contain only alphanumeric characters, dashes, and underscores/, +} + +export function testRejectsInvalidIdentifier( + runCommand: (...args: string[]) => Promise, + paramName: string, + type: ParamType, + otherRequiredArgs: string[] = [] +) { + test(`rejects ${paramName} with special characters`, async () => { + await expectValidationError( + () => runCommand(`--${paramName}`, 'PRJ/123', ...otherRequiredArgs), + paramTypePatterns[type] + ) + }) +} + +export async function expectValidationError( + runner: () => Promise, + expectedPattern: RegExp +): Promise { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect(runner()).rejects.toThrow('process.exit') + const errorOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(errorOutput).toMatch(expectedPattern) + } finally { + exitSpy.mockRestore() + errorSpy.mockRestore() + } +} + +export const test = baseTest.extend<{ project: TestProject }>({ + // eslint-disable-next-line no-empty-pattern, @typescript-eslint/no-empty-object-type + project: async ({}: {}, use) => { + if (!isRealApi) { + await use({ code: 'PRJ', id: 'mock-id' }) + return + } + const env = configureEnv() + const project = await createTestProject(env.baseURL, env.token) + await use(project) + await deleteTestProject(env.baseURL, project.code) + }, +}) diff --git a/src/tests/api/test-plans/create.spec.ts b/src/tests/api/test-plans/create.spec.ts new file mode 100644 index 0000000..af120b6 --- /dev/null +++ b/src/tests/api/test-plans/create.spec.ts @@ -0,0 +1,96 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { CreateTestPlanResponse } from '../../../api/test-plans' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post(`${baseURL}/api/public/v0/project/:projectCode/plan`, async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 1 }) + }) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a test plan', async ({ project }) => { + const body = JSON.stringify({ + title: 'Plan', + runs: [ + { + title: 'Run 1', + type: 'static', + queryPlans: [{ tcaseIds: ['abc'] }], + }, + ], + }) + const result = await runCommand('--project-code', project.code, '--body', body) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Plan', + runs: [ + { + title: 'Run 1', + type: 'static', + queryPlans: [{ tcaseIds: ['abc'] }], + }, + ], + }) + expect(result).toEqual({ id: 1 }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--body', + JSON.stringify({ + title: 'Plan', + runs: [{ title: 'Run', type: 'static', queryPlans: [{ tcaseIds: ['abc'] }] }], + }), + ]) +}) + +test('creates a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const result = await runCli( + 'api', + 'test-plans', + 'create', + '--project-code', + project.code, + '--body', + JSON.stringify({ + title: 'Live Plan', + runs: [ + { + title: 'Run 1', + type: 'static', + queryPlans: [{ tcaseIds: [tcase.id] }], + }, + ], + }) + ) + expect(result).toHaveProperty('id') +}) diff --git a/src/tests/api/users/list.spec.ts b/src/tests/api/users/list.spec.ts new file mode 100644 index 0000000..7157b5b --- /dev/null +++ b/src/tests/api/users/list.spec.ts @@ -0,0 +1,21 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect } from 'vitest' +import { test, baseURL, token, useMockServer, runCli } from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'users', 'list', ...args) + +describe('mocked', () => { + const mockUsers = [{ id: 1, email: 'user@example.com', name: 'User', role: 'admin' }] + + useMockServer( + http.get(`${baseURL}/api/public/v0/users`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + return HttpResponse.json({ users: mockUsers }) + }) + ) + + test('lists all users', async () => { + const result = await runCommand() + expect(result).toEqual(mockUsers) + }) +}) diff --git a/src/tests/api/utils.spec.ts b/src/tests/api/utils.spec.ts new file mode 100644 index 0000000..5682888 --- /dev/null +++ b/src/tests/api/utils.spec.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join, relative } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest' +import { z } from 'zod' +import { + parseAndValidateJsonArg, + validateWithSchema, + validateIntId, + validateProjectCode, + validateResourceId, +} from '../../commands/api/utils' +import { withBaseUrl } from '../../api/utils' + +describe('parseAndValidateJsonArg', () => { + let tempDir: string + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'qas-api-utils-')) + }) + + afterAll(() => { + rmSync(tempDir, { recursive: true }) + }) + + const passthrough = z.unknown() + + test('parses inline JSON', () => { + expect( + parseAndValidateJsonArg('[{"tcaseIds": ["abc"]}]', '--query-plans', passthrough) + ).toEqual([{ tcaseIds: ['abc'] }]) + }) + + test('parses JSON from @filename', () => { + const filePath = join(tempDir, 'plans.json') + writeFileSync(filePath, '[{"folderIds": [1, 2]}]') + expect(parseAndValidateJsonArg(`@${filePath}`, '--query-plans', passthrough)).toEqual([ + { folderIds: [1, 2] }, + ]) + }) + + test('parses JSON from relative @./filename', () => { + const filePath = join(tempDir, 'relative.json') + writeFileSync(filePath, '{"key": "value"}') + const relativePath = `./${relative(process.cwd(), filePath)}` + expect(parseAndValidateJsonArg(`@${relativePath}`, '--data', passthrough)).toEqual({ + key: 'value', + }) + }) + + test('throws on invalid inline JSON with helpful message', () => { + expect(() => parseAndValidateJsonArg('not-json', '--query-plans', passthrough)).toThrow( + /Failed to parse --query-plans as JSON/ + ) + expect(() => parseAndValidateJsonArg('not-json', '--query-plans', passthrough)).toThrow( + /@filename/ + ) + }) + + test('throws on bare @ with no filename', () => { + expect(() => parseAndValidateJsonArg('@', '--query-plans', passthrough)).toThrow( + /must be followed by a file path/ + ) + }) + + test('throws on @filename when file does not exist', () => { + expect(() => + parseAndValidateJsonArg('@nonexistent.json', '--query-plans', passthrough) + ).toThrow(/File not found for --query-plans: nonexistent.json/) + }) + + test('throws on @filename when file contains invalid JSON', () => { + const filePath = join(tempDir, 'bad.json') + writeFileSync(filePath, '{not valid json}') + expect(() => parseAndValidateJsonArg(`@${filePath}`, '--query-plans', passthrough)).toThrow( + /Failed to parse JSON from file/ + ) + }) + + test('parses and validates valid JSON against a schema', () => { + const schema = z.array(z.object({ id: z.string() })).min(1, 'must have at least one item') + expect(parseAndValidateJsonArg('[{"id": "abc"}]', '--items', schema)).toEqual([{ id: 'abc' }]) + }) + + test('throws parse error for invalid JSON before validation', () => { + const schema = z.array(z.object({ id: z.string() })).min(1, 'must have at least one item') + expect(() => parseAndValidateJsonArg('bad', '--items', schema)).toThrow( + /Failed to parse --items as JSON/ + ) + }) + + test('throws validation error for valid JSON that fails schema', () => { + const schema = z.array(z.object({ id: z.string() })).min(1, 'must have at least one item') + expect(() => parseAndValidateJsonArg('[]', '--items', schema)).toThrow( + /Validation failed for --items/ + ) + expect(() => parseAndValidateJsonArg('[]', '--items', schema)).toThrow( + /must have at least one item/ + ) + }) + + test('shows index in error for array of objects', () => { + const schema = z.array(z.object({ id: z.string(), count: z.number() })) + expect(() => + parseAndValidateJsonArg( + '[{"id": "ok", "count": 1}, {"id": 123, "count": "bad"}]', + '--items', + schema + ) + ).toThrow(/1\.id:/) + expect(() => + parseAndValidateJsonArg( + '[{"id": "ok", "count": 1}, {"id": 123, "count": "bad"}]', + '--items', + schema + ) + ).toThrow(/1\.count:/) + }) + + test('shows index in error for array of primitives', () => { + const schema = z.array(z.number()) + expect(() => parseAndValidateJsonArg('[1, 2, "three"]', '--values', schema)).toThrow(/2:/) + }) + + test('shows multiple validation errors for a single field', () => { + const schema = z.object({ + code: z + .string() + .min(3, 'must be at least 3 characters') + .refine((s) => /^[A-Z]+$/.test(s), 'must contain only uppercase letters'), + }) + expect(() => parseAndValidateJsonArg('{"code": "ab"}', '--data', schema)).toThrow( + /must be at least 3 characters/ + ) + expect(() => parseAndValidateJsonArg('{"code": "ab"}', '--data', schema)).toThrow( + /must contain only uppercase letters/ + ) + }) +}) + +describe('validateResourceId', () => { + test('accepts alphanumeric, dashes, and underscores', () => { + expect(() => validateResourceId(['abc-123_DEF', '--tcase-id'])).not.toThrow() + }) + + test('accepts multiple params', () => { + expect(() => validateResourceId(['abc', '--tcase-id'], ['def-123', '--other-id'])).not.toThrow() + }) + + test('rejects special characters', () => { + expect(() => validateResourceId(['abc!@#', '--tcase-id'])).toThrow( + /--tcase-id must contain only alphanumeric characters, dashes, and underscores/ + ) + }) + + test('rejects empty string', () => { + expect(() => validateResourceId(['', '--tcase-id'])).toThrow(/--tcase-id/) + }) + + test('reports all failing params', () => { + expect(() => + validateResourceId(['valid', '--a'], ['in valid', '--b'], ['al$o bad', '--c']) + ).toThrow(/--b.*\n.*--c/s) + }) +}) + +describe('validateProjectCode', () => { + test('accepts alphanumeric characters', () => { + expect(() => validateProjectCode(['PRJ1', '--project-code'])).not.toThrow() + }) + + test('accepts lowercase letters and digits', () => { + expect(() => validateProjectCode(['abc123', '--code'])).not.toThrow() + }) + + test('rejects dashes', () => { + expect(() => validateProjectCode(['PRJ-1', '--project-code'])).toThrow( + /--project-code must contain only latin letters and digits/ + ) + }) + + test('rejects underscores', () => { + expect(() => validateProjectCode(['PRJ_1', '--project-code'])).toThrow( + /--project-code must contain only latin letters and digits/ + ) + }) + + test('rejects special characters', () => { + expect(() => validateProjectCode(['PRJ!', '--code'])).toThrow( + /--code must contain only latin letters and digits/ + ) + }) + + test('rejects empty string', () => { + expect(() => validateProjectCode(['', '--project-code'])).toThrow(/--project-code/) + }) + + test('reports all failing params', () => { + expect(() => validateProjectCode(['GOOD', '--a'], ['BA-D', '--b'], ['WOR$E', '--c'])).toThrow( + /--b.*\n.*--c/s + ) + }) +}) + +describe('validateIntId', () => { + test('accepts positive integers', () => { + expect(() => validateIntId([1, '--run-id'])).not.toThrow() + expect(() => validateIntId([100, '--run-id'])).not.toThrow() + }) + + test('accepts multiple params', () => { + expect(() => validateIntId([1, '--run-id'], [5, '--id'])).not.toThrow() + }) + + test('rejects zero', () => { + expect(() => validateIntId([0, '--run-id'])).toThrow(/--run-id must be a positive integer/) + }) + + test('rejects negative numbers', () => { + expect(() => validateIntId([-5, '--run-id'])).toThrow(/--run-id must be a positive integer/) + }) + + test('rejects non-integer numbers', () => { + expect(() => validateIntId([1.5, '--run-id'])).toThrow(/--run-id must be a positive integer/) + }) + + test('rejects NaN', () => { + expect(() => validateIntId([NaN, '--run-id'])).toThrow(/--run-id must be a positive integer/) + }) + + test('reports all failing params', () => { + expect(() => validateIntId([1, '--a'], [-1, '--b'], [0, '--c'])).toThrow(/--b.*\n.*--c/s) + }) +}) + +describe('validateWithSchema', () => { + const schema = z.object({ + name: z.string().min(1, 'name must not be empty'), + count: z.number().int().positive('count must be a positive integer'), + }) + + test('returns validated value on success', () => { + const result = validateWithSchema({ name: 'test', count: 5 }, '--options', schema) + expect(result).toEqual({ name: 'test', count: 5 }) + }) + + test('throws with formatted error on validation failure', () => { + expect(() => validateWithSchema({ name: '', count: -1 }, '--options', schema)).toThrow( + /Validation failed for --options/ + ) + expect(() => validateWithSchema({ name: '', count: -1 }, '--options', schema)).toThrow( + /name: name must not be empty/ + ) + expect(() => validateWithSchema({ name: '', count: -1 }, '--options', schema)).toThrow( + /count: count must be a positive integer/ + ) + }) + + test('includes field path in error message', () => { + const arraySchema = z.array(z.object({ id: z.string() })) + expect(() => validateWithSchema([{ id: 'ok' }, { id: 123 }], '--items', arraySchema)).toThrow( + /1\.id:/ + ) + }) +}) + +describe('withBaseUrl', () => { + test('strips trailing slashes from base URL', async () => { + const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) + const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com/') + await fetcher('/api/test') + expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) + }) + + test('strips multiple trailing slashes', async () => { + const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) + const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com///') + await fetcher('/api/test') + expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) + }) + + test('works with base URL without trailing slash', async () => { + const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) + const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com') + await fetcher('/api/test') + expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) + }) +}) diff --git a/src/tests/global-setup.ts b/src/tests/global-setup.ts new file mode 100644 index 0000000..a60f249 --- /dev/null +++ b/src/tests/global-setup.ts @@ -0,0 +1,35 @@ +import type { TestProject } from 'vitest/node' + +const { QAS_TEST_URL, QAS_TEST_USERNAME, QAS_TEST_PASSWORD, QAS_DEV_AUTH } = process.env + +declare module 'vitest' { + export interface ProvidedContext { + sessionToken: string | null + } +} + +async function login(baseURL: string): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (QAS_DEV_AUTH) { + headers['Cookie'] = `_devauth=${QAS_DEV_AUTH}` + } + const resp = await fetch(`${baseURL}/api/auth/login`, { + method: 'POST', + headers, + body: JSON.stringify({ email: QAS_TEST_USERNAME, password: QAS_TEST_PASSWORD }), + }) + if (!resp.ok) { + throw new Error(`Login failed: ${resp.status} ${resp.statusText}`) + } + const data: { token: string } = await resp.json() + return data.token +} + +export default async function setup(project: TestProject) { + if (!QAS_TEST_URL || !QAS_TEST_USERNAME || !QAS_TEST_PASSWORD) { + project.provide('sessionToken', null) + return + } + const token = await login(QAS_TEST_URL) + project.provide('sessionToken', token) +} diff --git a/src/tests/missing-subcommand-help.spec.ts b/src/tests/missing-subcommand-help.spec.ts new file mode 100644 index 0000000..78f1a32 --- /dev/null +++ b/src/tests/missing-subcommand-help.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from 'vitest' + +import { run } from '../commands/main.js' + +/** + * When a user runs a command group without specifying a leaf subcommand + * (e.g. `qasphere api` or `qasphere api projects`), the CLI should + * display help text instead of "An unexpected error occurred." + */ +describe('missing subcommand shows help', () => { + function setup() { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as typeof process.exit) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + return { + exitSpy, + stderrSpy, + logSpy, + collectLog: () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'), + collectStderr: () => stderrSpy.mock.calls.map((c) => c.join(' ')).join('\n'), + reset() { + exitSpy.mockClear() + stderrSpy.mockClear() + logSpy.mockClear() + }, + restore() { + exitSpy.mockRestore() + stderrSpy.mockRestore() + logSpy.mockRestore() + }, + } + } + + test.each([ + { args: ['api'], label: '`api`' }, + { args: ['api', 'projects'], label: '`api projects`' }, + { args: ['api', 'runs'], label: '`api runs`' }, + ])('$label without subcommand shows help', async ({ args }) => { + const spies = setup() + try { + // Capture the canonical help text via -h (printed to stdout). + // Vitest replaces process.exit with a function that throws, so we catch it. + try { + await run([...args, '-h']) + } catch { + // expected: vitest's process.exit always throws + } + const helpText = spies.collectLog() + expect(helpText).toContain('Commands:') + expect(helpText).toContain(args.join(' ')) + + spies.reset() + + // Run without subcommand — should print the same help to stderr + await run(args) + expect(spies.exitSpy).toHaveBeenCalledWith(1) + const stderrOutput = spies.collectStderr() + expect(stderrOutput).not.toContain('An unexpected error occurred') + expect(stderrOutput).toContain(helpText) + } finally { + spies.restore() + } + }) + + test('no args shows help with exit code 0', async () => { + const spies = setup() + try { + await run([]) + expect(spies.exitSpy).toHaveBeenCalledWith(0) + } finally { + spies.restore() + } + }) +}) diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index 548ef27..a29fde1 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -3,13 +3,9 @@ import { setupServer } from 'msw/node' import { unlinkSync, readdirSync } from 'node:fs' import { afterAll, beforeAll, beforeEach, expect, test, describe, afterEach } from 'vitest' import { run } from '../commands/main' -import { - CreateTCasesRequest, - CreateTCasesResponse, - Folder, - PaginatedResponse, - TCase, -} from '../api/schemas' +import { PaginatedResponse } from '../api/schemas' +import { CreateTCasesRequest, CreateTCasesResponse, TCase } from '../api/tcases' +import { Folder } from '../api/folders' import { DEFAULT_FOLDER_TITLE } from '../utils/result-upload/ResultUploadCommandHandler' import { setMaxResultsInRequest } from '../utils/result-upload/ResultUploader' import { runTestCases } from './fixtures/testcases' diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 77cb415..1d9c402 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -5,7 +5,7 @@ import { dirname } from 'node:path' import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { MarkerParser } from './MarkerParser' import { Api, createApi } from '../../api' -import { TCase } from '../../api/schemas' +import { TCase } from '../../api/tcases' import { ParseResult, TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' import { parseJUnitXml } from './parsers/junitXmlParser' @@ -121,7 +121,7 @@ export class ResultUploadCommandHandler { console.log(chalk.blue(`Detected project code: ${projectCode}`)) } - if (!(await this.api.projects.checkProjectExists(projectCode))) { + if (!(await this.api.projects.checkExists(projectCode))) { return printErrorThenExit(`Project ${projectCode} does not exist`) } @@ -215,7 +215,7 @@ export class ResultUploadCommandHandler { ) for (let page = 1; ; page++) { - const response = await this.api.testcases.getTCasesBySeq(projectCode, { + const response = await this.api.testCases.getBySeq(projectCode, { seqIds: tcaseMarkers, page, limit: DEFAULT_PAGE_SIZE, @@ -284,7 +284,7 @@ export class ResultUploadCommandHandler { // Ideally, there shouldn't be the need to fetch more than one page. let defaultFolderId = null for (let page = 1; ; page++) { - const response = await this.api.folders.getFoldersPaginated(projectCode, { + const response = await this.api.folders.getPaginated(projectCode, { search: DEFAULT_FOLDER_TITLE, page, limit: DEFAULT_PAGE_SIZE, @@ -306,7 +306,7 @@ export class ResultUploadCommandHandler { const apiTCasesMap: Record = {} if (defaultFolderId) { for (let page = 1; ; page++) { - const response = await this.api.testcases.getTCasesPaginated(projectCode, { + const response = await this.api.testCases.getPaginated(projectCode, { folders: [defaultFolderId], page, limit: DEFAULT_PAGE_SIZE, @@ -350,7 +350,7 @@ export class ResultUploadCommandHandler { } // Create new test cases and update the placeholders with the actual test case IDs - const { tcases } = await this.api.testcases.createTCases(projectCode, { + const { tcases } = await this.api.testCases.createBatch(projectCode, { folderPath: [DEFAULT_FOLDER_TITLE], tcases: finalTCasesToCreate.map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), }) @@ -406,7 +406,7 @@ export class ResultUploadCommandHandler { console.log(chalk.blue(`Creating a new test run for project: ${projectCode}`)) try { - const response = await this.api.runs.createRun(projectCode, { + const response = await this.api.runs.create(projectCode, { title, description: 'Test run created through automation pipeline', type: 'static_struct', diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index 6c1c2a4..e6865c2 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -1,6 +1,6 @@ import { Arguments } from 'yargs' import chalk from 'chalk' -import { RunTCase } from '../../api/schemas' +import { RunTCase } from '../../api/runs' import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' import { Attachment, TestCaseResult } from './types' @@ -31,9 +31,7 @@ export class ResultUploader { } async handle(results: TestCaseResult[], runFailureLogs?: string) { - const tcases = await this.api.runs - .getRunTCases(this.project, this.run) - .catch(printErrorThenExit) + const tcases = await this.api.runs.getTCases(this.project, this.run).catch(printErrorThenExit) const { results: mappedResults, missing } = this.mapTestCaseResults(results, tcases) this.validateAndPrintMissingTestCases(missing) @@ -46,7 +44,7 @@ export class ResultUploader { ) if (runFailureLogs) { - await this.api.runs.createRunLog(this.project, this.run, { comment: runFailureLogs }) + await this.api.runs.createLog(this.project, this.run, { comment: runFailureLogs }) console.log(`Uploaded run failure logs`) } @@ -261,7 +259,7 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} filename: attachment.filename, })) - const uploaded = await this.api.files.uploadFiles(files) + const uploaded = await this.api.files.upload(files) uploadedCount += batch.length loader.setText( @@ -315,7 +313,7 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} const endIdx = Math.min(startIdx + MAX_RESULTS_IN_REQUEST, results.length) const batch = results.slice(startIdx, endIdx) - await this.api.runs.createResults(this.project, this.run, { + await this.api.results.createBatch(this.project, this.run, { items: batch.map(({ tcase, result }) => ({ tcaseId: tcase.id, status: result.status, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2a62a4b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globalSetup: './src/tests/global-setup.ts', + tags: [{ name: 'live' }, { name: 'mocked' }], + }, +})