Compare commits

..

3 Commits

Author SHA1 Message Date
ae0f4c2675 test: close tea and error-classifier coverage gaps, raise thresholds
All checks were successful
CI / test (pull_request) Successful in 53s
CI / test (push) Successful in 21s
Cover the previously untested credential and error-classification paths:
- tea.ts: failing/invalid/non-array `tea login list`, a failing token
  helper, and an empty token, all driven through the CLI seam via the fake
  tea harness (now parametrizable for exit codes and output).
- errors.ts: a unit-tier test of classifyHttpError for the pure-classifier
  branches no shipped command reaches yet (issue/pull 404s, the
  already-classified passthrough, transport failures, no-body defaults).

errors.ts is now fully covered and tea.ts is at 96% (the remainder is
defensive code unreachable through the seam). Overall coverage rose from
~87% to ~93% statements / ~89% branches; the ratchet is raised to
92/87/95/92 accordingly.
2026-07-11 09:15:15 -04:00
94e81966be test: add coverage reporting with regression thresholds
Add v8 coverage via a test:coverage script and vitest config: measure src
only, exclude the types-only deps.ts and the bin entrypoint main.ts, and
gate on ratcheted thresholds (statements 85, branches 78, functions 90,
lines 85) set just under current coverage. CI runs the unit+integration
tier through coverage so a real drop fails the build.
2026-07-11 09:06:11 -04:00
1d18472297 ci: add end-to-end tier and Gitea Actions workflow (task 0019)
Add the end-to-end test tier: a Gitea Actions workflow (GitHub-compatible)
that runs the unit+integration tiers plus e2e tests against a pinned
disposable Gitea service container. The e2e suite provisions its own repo,
token, and seed data over HTTP and drives the real CLI seam, gated on
GITEA_AXI_E2E_URL so the default suite stays dependency-free.

A fixture-vs-live shape guard anchors the issue-list FieldDef paths against
both the recorded fixture and the live response, failing on divergence.
2026-07-11 09:01:12 -04:00
11 changed files with 1580 additions and 13 deletions

View File

@@ -19,8 +19,36 @@ The workflow file stays GitHub-Actions-compatible so the GitHub mirror can adopt
## Acceptance criteria
- [ ] A workflow runs on push/PR on Gitea Actions, executing the unit and integration tiers (the local vitest suite) and the end-to-end tier
- [ ] The disposable Gitea runs as a service container pinned to a specific stable image tag
- [ ] End-to-end tests provision their own repo, token, and seed data on the disposable instance, then assert real CLI output and exit codes for at least the tracer command set
- [ ] The workflow uses only syntax that works verbatim (or near-verbatim) on GitHub Actions
- [ ] A fixture-vs-live divergence in a covered response shape fails the end-to-end tier
- [x] A workflow runs on push/PR on Gitea Actions, executing the unit and integration tiers (the local vitest suite) and the end-to-end tier
- [x] The disposable Gitea runs as a service container pinned to a specific stable image tag
- [x] End-to-end tests provision their own repo, token, and seed data on the disposable instance, then assert real CLI output and exit codes for at least the tracer command set
- [x] The workflow uses only syntax that works verbatim (or near-verbatim) on GitHub Actions
- [x] A fixture-vs-live divergence in a covered response shape fails the end-to-end tier
## Implementation Notes
**Tier split.**
The end-to-end tier lives under `test/e2e/` with its own `vitest.e2e.config.ts` and a `test:e2e` npm script; the default `vitest.config.ts` now excludes `test/e2e/**` so `npm test` stays the fast unit+integration tiers with no external dependency.
The e2e suite is gated on `GITEA_AXI_E2E_URL` via `describe.skipIf`, so it skips cleanly (exit 0) when no live instance is configured; `passWithNoTests` guards against a "no tests found" failure in that state.
**In-Node provisioning, no `docker exec`.**
`test/e2e/provision.ts` brings a fresh Gitea to a usable state entirely over HTTP: it waits on `GET /api/v1/version`, registers the first user through the web `sign_up` form (Gitea makes the first account the site admin), scraping and echoing the double-submit CSRF token, then mints a scoped API token via HTTP Basic auth and creates the repo + seed issues with it.
This keeps provisioning identical on Gitea Actions, GitHub Actions, and a developer's local `docker run gitea/gitea`, with no container-shell access required.
**Portable networking.**
The workflow job runs inside a `node:20-bookworm` container so the `gitea` service container is reachable by service name (`gitea:3000`) on both Gitea Actions and GitHub Actions, sidestepping the host-`localhost` vs. service-name difference between the two platforms — this is what keeps the file near-verbatim GitHub-compatible (AC4).
**Fixture-vs-live guard (AC5).**
Rather than hardcode expected keys, the shape guard anchors on one exported contract, `COVERED_ISSUE_PATHS` — the exact dotted paths the issue-list `FieldDef` extractors read — and asserts it holds on *both* the recorded `fixtures/issues-open.json` and the live response.
A drift in either (a fixture edited out of shape, or a live field renamed such as `user``author`) fails the tier.
**Pinned tag.**
`gitea/gitea:1.23.5`, chosen to match the gitea-js client line (`^1.23.0`) so the e2e tier exercises the response shapes the client was generated against; bumped deliberately by the operator.
**Deviation from the letter of the ACs.**
The workflow adds a `typecheck` step that no AC names; it is cheap CI hygiene and kept deliberately.
**Verified against a live Gitea 1.23.5.**
The full e2e tier was run against a real disposable `gitea/gitea:1.23.5` container (all seven tests green), which also confirms the pinned tag pulls.
The live run surfaced one thing the earlier mock run could not: creating a repo under a user (`POST /user/repos`) requires the token scope `write:user` on Gitea 1.23, not `write:repository` — the token scopes in `provision.ts` were corrected to `["write:user", "write:repository", "write:issue"]`.
This is exactly the fixture-vs-live class of divergence the tier exists to catch.

54
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,54 @@
# CI for gitea-axi. Runs on Gitea Actions on the operator's instance; the syntax
# is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file
# nearly verbatim (copy it to .github/workflows/).
#
# The job runs inside a node container so the disposable Gitea service is
# reachable by its service name (`gitea:3000`) on both Gitea Actions and GitHub
# Actions — avoiding the host-vs-service-name networking difference between the
# two platforms.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container: node:20-bookworm
services:
gitea:
# Pinned to a specific stable tag, bumped deliberately (not floating).
# Kept on the 1.23 line to match the gitea-js client (^1.23.0), so the
# e2e tier exercises response shapes the client was generated against.
image: gitea/gitea:1.23.5
env:
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /data/gitea/gitea.db
GITEA__server__ROOT_URL: http://gitea:3000/
GITEA__server__HTTP_PORT: "3000"
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
GITEA__log__LEVEL: warn
env:
# The end-to-end tier provisions and drives the CLI against this instance.
GITEA_AXI_E2E_URL: http://gitea:3000
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit and integration tiers (with coverage thresholds)
run: npm run test:coverage
- name: End-to-end tier
run: npm run test:e2e

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/
dist/
coverage/

863
package-lock.json generated
View File

@@ -18,6 +18,7 @@
},
"devDependencies": {
"@types/node": "^20.19.0",
"@vitest/coverage-v8": "^3.2.7",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
},
@@ -25,6 +26,80 @@
"node": ">=20"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -467,6 +542,55 @@
"node": ">=18"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -474,6 +598,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -904,6 +1050,40 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz",
"integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
"ast-v8-to-istanbul": "^0.3.3",
"debug": "^4.4.1",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.2.7",
"vitest": "3.2.7"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -1019,6 +1199,32 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1029,6 +1235,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axi-sdk-js": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/axi-sdk-js/-/axi-sdk-js-0.1.8.tgz",
@@ -1041,6 +1266,29 @@
"node": ">=20"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1078,6 +1326,41 @@
"node": ">= 16"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1106,6 +1389,20 @@
"node": ">=6"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1193,6 +1490,23 @@
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1214,6 +1528,165 @@
"integrity": "sha512-f4+UPoWgDetZeZ+Awo5iI1nVdO5bjxA8+2QCeLo3oYWUYxKyzLfXgbW1EPD635wb8hLgS0DRBu5XhtiuYKEeUA==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"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": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -1228,6 +1701,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1238,6 +1718,60 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1264,6 +1798,40 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1375,6 +1943,42 @@
"fsevents": "~2.3.2"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1382,6 +1986,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1406,6 +2023,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -1419,6 +2140,34 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1672,6 +2421,22 @@
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1688,6 +2453,104 @@
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
}
}
}

View File

@@ -18,7 +18,9 @@
"prepublishOnly": "npm run build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
},
"dependencies": {
"@toon-format/toon": "^2.3.0",
@@ -27,6 +29,7 @@
},
"devDependencies": {
"@types/node": "^20.19.0",
"@vitest/coverage-v8": "^3.2.7",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
}

View File

@@ -0,0 +1,95 @@
import { AxiError } from "axi-sdk-js";
import { describe, expect, it } from "vitest";
import { classifyHttpError } from "../src/errors.js";
/**
* Unit-tier coverage of the HTTP error classifier (a pure function, no I/O).
* The integration tier drives the classifier through the issue-list seam, but
* some branches belong to paths no shipped command reaches yet — pull-request
* 404s, the already-classified passthrough, and non-HTTP transport failures —
* so they are exercised directly here against crafted inputs.
*/
function httpError(status: number, url: string, body?: unknown) {
return { status, url, error: body };
}
const REPO = "http://gitea.example/api/v1/repos/o/r";
describe("classifyHttpError", () => {
it("passes an already-classified AxiError through unchanged", () => {
const original = new AxiError("boom", "FORBIDDEN", ["hint"]);
expect(classifyHttpError(original)).toBe(original);
});
it("classifies a 404 on an issue path as ISSUE_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/issues/42`));
expect(result.code).toBe("ISSUE_NOT_FOUND");
expect(result.message).toContain("#42");
});
it("classifies a 404 on a pull path as PR_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/pulls/7`));
expect(result.code).toBe("PR_NOT_FOUND");
expect(result.message).toContain("#7");
});
it("classifies a 404 on the repo subtree as REPO_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/issues`));
expect(result.code).toBe("REPO_NOT_FOUND");
expect(result.message).toContain("o/r");
});
it("classifies a 404 on a non-repo path as UNKNOWN", () => {
const result = classifyHttpError(httpError(404, "http://gitea.example/api/v1/version"));
expect(result.code).toBe("UNKNOWN");
});
it("falls back to the raw url when the response url does not parse", () => {
const result = classifyHttpError(httpError(404, "::not a url::"));
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("::not a url::");
});
it("uses the body message when present, and a default when absent", () => {
expect(classifyHttpError(httpError(401, REPO, { message: "token expired" })).message).toBe(
"token expired",
);
expect(classifyHttpError(httpError(401, REPO, {})).message).toBe("Authentication required");
});
it("maps 403 and every validation status (405/409/422) and 429 to their codes", () => {
expect(classifyHttpError(httpError(403, REPO, {})).code).toBe("FORBIDDEN");
expect(classifyHttpError(httpError(405, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(409, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(422, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(429, REPO, {})).code).toBe("RATE_LIMITED");
});
it("maps an unexpected status to UNKNOWN, with and without a body message", () => {
expect(classifyHttpError(httpError(500, REPO, { message: "kaboom" })).message).toContain(
"kaboom",
);
const bare = classifyHttpError(httpError(503, REPO, {}));
expect(bare.code).toBe("UNKNOWN");
expect(bare.message).toContain("503");
});
it("classifies a plain Error transport failure as UNKNOWN", () => {
const result = classifyHttpError(new Error("network down"));
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("network down");
});
it("includes the underlying cause when a transport failure carries one", () => {
const error = new Error("fetch failed", { cause: new Error("ECONNREFUSED") });
const result = classifyHttpError(error);
expect(result.message).toContain("fetch failed");
expect(result.message).toContain("ECONNREFUSED");
});
it("stringifies a non-Error thrown value", () => {
const result = classifyHttpError("just a string");
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("just a string");
});
});

View File

@@ -24,24 +24,49 @@ afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
interface SandboxOptions {
logins?: FakeLogin[];
token?: string;
/** Set false to omit the tea binary entirely (TEA_NOT_INSTALLED path). */
tea?: boolean;
/** Raw stdout for `tea login list`, overriding the logins JSON. */
listOutput?: string;
/** Exit code for `tea login list` (default 0). */
listExitCode?: number;
/** stderr line emitted by `tea login list` when it fails. */
listStderr?: string;
/** Raw stdout for `tea login helper get`, overriding the credential block. */
helperOutput?: string;
/** Exit code for `tea login helper get` (default 0). */
helperExitCode?: number;
/** stderr line emitted by `tea login helper get` when it fails. */
helperStderr?: string;
}
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
function makeSandbox(options: SandboxOptions): string {
const bin = join(root, `bin-${sandboxCounter++}`);
mkdirSync(bin);
symlinkSync(gitPath, join(bin, "git"));
symlinkSync(catPath, join(bin, "cat"));
if (options.tea !== false) {
const listOutput = options.listOutput ?? JSON.stringify(options.logins ?? []);
const helperOutput =
options.helperOutput ??
`protocol=http\nhost=fixture\nusername=u\npassword=${options.token ?? ""}`;
const script = `#!/bin/sh
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
cat <<'JSON'
${JSON.stringify(options.logins ?? [])}
JSON
exit 0
cat <<'LISTEOF'
${listOutput}
LISTEOF
${options.listStderr ? ` echo '${options.listStderr}' >&2\n` : ""} exit ${options.listExitCode ?? 0}
fi
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
cat > /dev/null
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
exit 0
cat <<'HELPEREOF'
${helperOutput}
HELPEREOF
${options.helperStderr ? ` echo '${options.helperStderr}' >&2\n` : ""} exit ${options.helperExitCode ?? 0}
fi
echo "unexpected tea invocation: $*" >&2
exit 1
@@ -254,4 +279,99 @@ describe("repository context detection", () => {
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
expect(stdout).toContain("--login fixture");
});
it("maps a failing `tea login list` to UNKNOWN, surfacing the stderr detail", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listExitCode: 1, listStderr: "config file is corrupt" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("tea login list");
expect(stdout).toContain("config file is corrupt");
});
it("maps invalid JSON from `tea login list` to UNKNOWN", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: "not json at all {" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("invalid JSON");
});
it("maps non-array JSON from `tea login list` to UNKNOWN", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: '{"not":"an array"}' });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("unexpected output");
});
it("tolerates login entries with missing fields", async () => {
// A login object with no name/url/ssh_host exercises the field fallbacks;
// it matches no host, so resolution ends in REPO_NOT_FOUND.
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: "[{}]" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: REPO_NOT_FOUND");
});
it("maps a failing token helper to AUTH_REQUIRED with a repair hint", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
helperExitCode: 1,
helperStderr: "credential store is locked",
});
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: AUTH_REQUIRED");
expect(stdout).toContain("tea login edit fixture");
expect(stdout).toContain("credential store is locked");
});
it("maps an empty token from the helper to AUTH_REQUIRED", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
// A credential block that carries no usable password value.
helperOutput: "protocol=http\nhost=gitea.example.com\nusername=u\npassword=",
});
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: AUTH_REQUIRED");
expect(stdout).toContain("tea login edit fixture");
});
});

234
test/e2e/provision.ts Normal file
View File

@@ -0,0 +1,234 @@
/**
* Provisioning for the end-to-end tier: bring a fresh, disposable Gitea instance
* to a usable state entirely over its HTTP API, with no `docker exec` or shell
* access to the container. Everything here runs identically on Gitea Actions,
* GitHub Actions, and a developer's local `docker run gitea/gitea`.
*
* Bootstrap chain:
* 1. Wait for the instance to answer `GET /api/v1/version`.
* 2. Register the first user through the web sign-up form — Gitea makes the
* first registered account the site administrator.
* 3. Mint a scoped API token for that user via HTTP Basic auth (no CSRF).
* 4. Create a repository and seed issues with that token.
*/
export interface E2EInstance {
/** Instance base URL, without the /api/v1 suffix (what the CLI expects). */
baseUrl: string;
owner: string;
repo: string;
token: string;
/** Titles of the seeded open issues, in creation order (newest number last). */
openTitles: string[];
/** Title of the single seeded closed issue. */
closedTitle: string;
}
const USERNAME = "e2e-admin";
const PASSWORD = "e2e-admin-password-123";
const EMAIL = "e2e-admin@example.com";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForGitea(baseUrl: string): Promise<void> {
const deadline = Date.now() + 120_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/v1/version`);
if (res.ok) {
return;
}
lastError = new Error(`GET /api/v1/version returned ${res.status}`);
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw new Error(`Gitea at ${baseUrl} never became ready: ${String(lastError)}`);
}
/** A minimal cookie jar: keep the latest value per cookie name. */
function collectCookies(jar: Map<string, string>, res: Response): void {
for (const header of res.headers.getSetCookie()) {
const pair = header.split(";", 1)[0]!;
const eq = pair.indexOf("=");
if (eq > 0) {
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
}
function cookieHeader(jar: Map<string, string>): string {
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
/**
* Register the first account through the web sign-up form. Gitea protects the
* form with a double-submit CSRF token that must be scraped from the rendered
* HTML and echoed back alongside the matching cookie. Best-effort: a fresh
* instance succeeds here, and {@link mintToken} is the real gate on success (a
* pre-existing account from a local re-run is tolerated).
*/
async function registerFirstUser(baseUrl: string): Promise<void> {
const jar = new Map<string, string>();
const getRes = await fetch(`${baseUrl}/user/sign_up`);
collectCookies(jar, getRes);
const html = await getRes.text();
const csrf = html.match(/name="_csrf"\s+value="([^"]+)"/)?.[1];
if (!csrf) {
throw new Error("Could not find a CSRF token on the Gitea sign-up page");
}
const form = new URLSearchParams({
_csrf: csrf,
user_name: USERNAME,
email: EMAIL,
password: PASSWORD,
retype: PASSWORD,
});
await fetch(`${baseUrl}/user/sign_up`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: cookieHeader(jar),
},
body: form.toString(),
redirect: "manual",
});
}
function basicAuth(): string {
return `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64")}`;
}
async function mintToken(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/v1/users/${USERNAME}/tokens`, {
method: "POST",
headers: {
authorization: basicAuth(),
"content-type": "application/json",
},
body: JSON.stringify({
// write:user is what Gitea requires to create a repo under the user
// (POST /user/repos); write:repository/write:issue cover the repo + issue
// reads and writes the provisioning and CLI seam then perform.
name: `e2e-${Date.now()}`,
scopes: ["write:user", "write:repository", "write:issue"],
}),
});
if (res.status !== 201) {
throw new Error(
`Token creation failed (${res.status}); first-user registration likely did not take. Body: ${await res.text()}`,
);
}
const body = (await res.json()) as { sha1?: string };
if (!body.sha1) {
throw new Error("Token response had no sha1 field");
}
return body.sha1;
}
/**
* One authenticated Gitea API round-trip: attach the token, send an optional
* JSON body, and fail on any non-2xx. Returns the raw {@link Response} so callers
* can read the body or headers (e.g. the x-total-count the count line uses).
*/
async function apiRequest(
baseUrl: string,
method: string,
path: string,
token: string,
payload?: unknown,
): Promise<Response> {
const res = await fetch(`${baseUrl}/api/v1${path}`, {
method,
headers: {
authorization: `token ${token}`,
...(payload !== undefined ? { "content-type": "application/json" } : {}),
},
body: payload !== undefined ? JSON.stringify(payload) : undefined,
});
if (!res.ok) {
throw new Error(`${method} ${path} failed (${res.status}): ${await res.text()}`);
}
return res;
}
export async function provisionInstance(baseUrl: string): Promise<E2EInstance> {
const normalized = baseUrl.replace(/\/+$/, "");
await waitForGitea(normalized);
await registerFirstUser(normalized);
const token = await mintToken(normalized);
const repo = `e2e-repo-${Date.now()}`;
await apiRequest(normalized, "POST", "/user/repos", token, {
name: repo,
auto_init: true,
default_branch: "main",
private: false,
});
const openTitles = ["E2E first issue", "E2E second issue", "E2E third issue"];
for (const title of openTitles) {
await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, {
title,
body: `Seeded body for ${title}.`,
});
}
const closedTitle = "E2E closed issue";
const closedRes = await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, {
title: closedTitle,
body: "Seeded closed issue.",
});
const closed = (await closedRes.json()) as { number: number };
await apiRequest(normalized, "PATCH", `/repos/${USERNAME}/${repo}/issues/${closed.number}`, token, {
state: "closed",
});
return { baseUrl: normalized, owner: USERNAME, repo, token, openTitles, closedTitle };
}
/**
* The response-shape paths the issue-list command's FieldDef extractors read
* (see ISSUE_LIST_FIELDS in src/commands/issue.ts). This one contract anchors
* three things that must agree: the extractors, the recorded fixtures, and the
* live Gitea response. The end-to-end shape guard asserts both the fixture and
* the live payload satisfy it, so a divergence in either fails the tier.
*/
export const COVERED_ISSUE_PATHS = ["number", "title", "state", "created_at", "user.login"];
/** Whether `obj` has a defined value at a dotted `path` (e.g. "user.login"). */
export function hasPath(obj: unknown, path: string): boolean {
let value: unknown = obj;
for (const key of path.split(".")) {
if (typeof value !== "object" || value === null) {
return false;
}
value = (value as Record<string, unknown>)[key];
}
return value !== undefined && value !== null;
}
/**
* Fetch the raw issues-list response the CLI's issue-list command consumes, for
* the response-shape guard. Returns both the parsed array and the header the
* count line is built from.
*/
export async function fetchRawIssues(
instance: E2EInstance,
state: "open" | "closed" | "all",
): Promise<{ issues: Record<string, unknown>[]; totalCount: string | null }> {
const res = await apiRequest(
instance.baseUrl,
"GET",
`/repos/${instance.owner}/${instance.repo}/issues?type=issues&state=${state}&limit=30&page=1`,
instance.token,
);
return {
issues: (await res.json()) as Record<string, unknown>[],
totalCount: res.headers.get("x-total-count"),
};
}

128
test/e2e/tracer.test.ts Normal file
View File

@@ -0,0 +1,128 @@
import { readFileSync } from "node:fs";
import { beforeAll, describe, expect, it } from "vitest";
import { runCliTest } from "../harness.js";
import {
COVERED_ISSUE_PATHS,
fetchRawIssues,
hasPath,
provisionInstance,
type E2EInstance,
} from "./provision.js";
/**
* The end-to-end tier: the real CLI seam (argv in, stdout/exit-code out) against
* a live, disposable Gitea instance seeded over its own API. Gated on
* GITEA_AXI_E2E_URL so the default `npm test` (unit + integration tiers) never
* needs a live instance; CI sets it to the service container's URL.
*/
const E2E_URL = process.env.GITEA_AXI_E2E_URL;
const RELATIVE_TIME = /(just now|\d+(m|h|d|mo|y) ago)/;
describe.skipIf(!E2E_URL)("end-to-end: tracer command set", () => {
let instance: E2EInstance;
function env(overrides: Record<string, string> = {}): Record<string, string> {
return {
GITEA_AXI_API_URL: instance.baseUrl,
GITEA_AXI_TOKEN: instance.token,
GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`,
...overrides,
};
}
beforeAll(async () => {
instance = await provisionInstance(E2E_URL!);
}, 150_000);
it("lists seeded open issues with the default fields and a live count line", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list"], { env: env() });
expect(exitCode).toBe(0);
const lines = stdout.split("\n");
expect(lines[0]).toBe("count: 3 of 3 total");
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
for (const title of instance.openTitles) {
expect(stdout).toContain(title);
}
expect(stdout).toContain(`,open,${instance.owner},`);
expect(stdout).toMatch(RELATIVE_TIME);
expect(stdout).toMatch(/^help\[\d+\]:/m);
// No `type` field ever leaks into the row header.
expect(lines[1]).not.toContain("type");
});
it("filters to the seeded closed issue with --state closed", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--state", "closed"], {
env: env(),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 1 total");
expect(stdout).toContain(instance.closedTitle);
expect(stdout).toContain(",closed,");
});
it("honors --limit and reports the full total from X-Total-Count", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--limit", "1"], {
env: env(),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 3 total");
expect(stdout).toContain("issues[1]{number,title,state,author,created}:");
expect(stdout).toContain("issue list --limit <n>");
});
it("classifies a missing repository as REPO_NOT_FOUND with exit code 1", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: env({ GITEA_AXI_REPO: `${instance.owner}/does-not-exist-e2e` }),
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: REPO_NOT_FOUND");
});
it("rejects an invalid --state before touching the network, exit code 2", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--state", "banana"], {
env: env(),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
});
it("renders the home view against the live repo", async () => {
const { stdout, exitCode } = await runCliTest([], { env: env() });
expect(exitCode).toBe(0);
expect(stdout).toContain(`repo: ${instance.owner}/${instance.repo}`);
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
it("guards against fixture-vs-live divergence in the issues response shape", async () => {
// The recorded fixture that the integration tier asserts against. The
// covered-paths contract must hold on both it and the live payload; if
// either drifts from the shape the extractors read, this tier fails.
const fixture = JSON.parse(
readFileSync(new URL("../fixtures/issues-open.json", import.meta.url), "utf8"),
) as Record<string, unknown>[];
for (const recorded of fixture) {
for (const path of COVERED_ISSUE_PATHS) {
expect(hasPath(recorded, path), `fixture missing ${path}`).toBe(true);
}
}
const { issues, totalCount } = await fetchRawIssues(instance, "open");
// The count line is built from this header; its absence would silently
// change output, so it is part of the covered shape.
expect(totalCount).toBe("3");
expect(issues).toHaveLength(3);
for (const issue of issues) {
for (const path of COVERED_ISSUE_PATHS) {
expect(hasPath(issue, path), `live issue missing ${path}`).toBe(true);
}
expect((issue.user as Record<string, unknown>).login).toBe(instance.owner);
}
});
});

View File

@@ -2,6 +2,32 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Unit + integration tiers: fast, no external dependencies. The end-to-end
// tier under test/e2e needs a live Gitea instance and runs via `test:e2e`.
include: ["test/**/*.test.ts"],
exclude: ["test/e2e/**"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
// Measure the shipped source only; the CLI is exercised through its seam,
// so coverage reflects what those tiers actually reach.
include: ["src/**/*.ts"],
exclude: [
// Types-only: no runtime code to exercise.
"src/deps.ts",
// Bin entrypoint: process argv/stdout/EPIPE wiring that delegates to the
// covered runCli seam; not reached by the in-process seam tests.
"src/main.ts",
],
// A regression ratchet, not a target: set a few points under current
// coverage so a real drop fails CI while trivial churn does not. Raise
// these as coverage climbs; never lower them to make a red build pass.
thresholds: {
statements: 92,
branches: 87,
functions: 95,
lines: 92,
},
},
},
});

15
vitest.e2e.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// The end-to-end tier only: the same CLI seam as the integration tier, but
// driven against a live disposable Gitea instance (see test/e2e). Provision
// and network round-trips need a longer timeout than the fast tiers.
include: ["test/e2e/**/*.test.ts"],
testTimeout: 30_000,
hookTimeout: 150_000,
// Without a live instance (GITEA_AXI_E2E_URL unset) every suite skips;
// that must be a pass, not a "no tests found" failure.
passWithNoTests: true,
},
});